git-multimail: update to release 1.3.1
[git.git] / contrib / hooks / multimail / git_multimail.py
blob54ab4a49429fd9a3a31cdc471befe4852db369a6
1 #! /usr/bin/env python
3 __version__ = '1.3.1'
5 # Copyright (c) 2015 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 smtplib
60 try:
61 import ssl
62 except ImportError:
63 # Python < 2.6 do not have ssl, but that's OK if we don't use it.
64 pass
65 import time
66 import cgi
68 PYTHON3 = sys.version_info >= (3, 0)
70 if sys.version_info <= (2, 5):
71 def all(iterable):
72 for element in iterable:
73 if not element:
74 return False
75 return True
78 def is_ascii(s):
79 return all(ord(c) < 128 and ord(c) > 0 for c in s)
82 if PYTHON3:
83 def is_string(s):
84 return isinstance(s, str)
86 def str_to_bytes(s):
87 return s.encode(ENCODING)
89 def bytes_to_str(s):
90 return s.decode(ENCODING)
92 unicode = str
94 def write_str(f, msg):
95 # Try outputing with the default encoding. If it fails,
96 # try UTF-8.
97 try:
98 f.buffer.write(msg.encode(sys.getdefaultencoding()))
99 except UnicodeEncodeError:
100 f.buffer.write(msg.encode(ENCODING))
101 else:
102 def is_string(s):
103 try:
104 return isinstance(s, basestring)
105 except NameError: # Silence Pyflakes warning
106 raise
108 def str_to_bytes(s):
109 return s
111 def bytes_to_str(s):
112 return s
114 def write_str(f, msg):
115 f.write(msg)
117 def next(it):
118 return it.next()
121 try:
122 from email.charset import Charset
123 from email.utils import make_msgid
124 from email.utils import getaddresses
125 from email.utils import formataddr
126 from email.utils import formatdate
127 from email.header import Header
128 except ImportError:
129 # Prior to Python 2.5, the email module used different names:
130 from email.Charset import Charset
131 from email.Utils import make_msgid
132 from email.Utils import getaddresses
133 from email.Utils import formataddr
134 from email.Utils import formatdate
135 from email.Header import Header
138 DEBUG = False
140 ZEROS = '0' * 40
141 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
142 LOGEND = '-----------------------------------------------------------------------\n'
144 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
146 # It is assumed in many places that the encoding is uniformly UTF-8,
147 # so changing these constants is unsupported. But define them here
148 # anyway, to make it easier to find (at least most of) the places
149 # where the encoding is important.
150 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
153 REF_CREATED_SUBJECT_TEMPLATE = (
154 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
155 ' (now %(newrev_short)s)'
157 REF_UPDATED_SUBJECT_TEMPLATE = (
158 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
159 ' (%(oldrev_short)s -> %(newrev_short)s)'
161 REF_DELETED_SUBJECT_TEMPLATE = (
162 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
163 ' (was %(oldrev_short)s)'
166 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
167 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
170 REFCHANGE_HEADER_TEMPLATE = """\
171 Date: %(send_date)s
172 To: %(recipients)s
173 Subject: %(subject)s
174 MIME-Version: 1.0
175 Content-Type: text/%(contenttype)s; charset=%(charset)s
176 Content-Transfer-Encoding: 8bit
177 Message-ID: %(msgid)s
178 From: %(fromaddr)s
179 Reply-To: %(reply_to)s
180 X-Git-Host: %(fqdn)s
181 X-Git-Repo: %(repo_shortname)s
182 X-Git-Refname: %(refname)s
183 X-Git-Reftype: %(refname_type)s
184 X-Git-Oldrev: %(oldrev)s
185 X-Git-Newrev: %(newrev)s
186 X-Git-NotificationType: ref_changed
187 X-Git-Multimail-Version: %(multimail_version)s
188 Auto-Submitted: auto-generated
191 REFCHANGE_INTRO_TEMPLATE = """\
192 This is an automated email from the git hooks/post-receive script.
194 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
195 in repository %(repo_shortname)s.
200 FOOTER_TEMPLATE = """\
202 -- \n\
203 To stop receiving notification emails like this one, please contact
204 %(administrator)s.
208 REWIND_ONLY_TEMPLATE = """\
209 This update removed existing revisions from the reference, leaving the
210 reference pointing at a previous point in the repository history.
212 * -- * -- N %(refname)s (%(newrev_short)s)
214 O -- O -- O (%(oldrev_short)s)
216 Any revisions marked "omits" are not gone; other references still
217 refer to them. Any revisions marked "discards" are gone forever.
221 NON_FF_TEMPLATE = """\
222 This update added new revisions after undoing existing revisions.
223 That is to say, some revisions that were in the old version of the
224 %(refname_type)s are not in the new version. This situation occurs
225 when a user --force pushes a change and generates a repository
226 containing something like this:
228 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
230 N -- N -- N %(refname)s (%(newrev_short)s)
232 You should already have received notification emails for all of the O
233 revisions, and so the following emails describe only the N revisions
234 from the common base, B.
236 Any revisions marked "omits" are not gone; other references still
237 refer to them. Any revisions marked "discards" are gone forever.
241 NO_NEW_REVISIONS_TEMPLATE = """\
242 No new revisions were added by this update.
246 DISCARDED_REVISIONS_TEMPLATE = """\
247 This change permanently discards the following revisions:
251 NO_DISCARDED_REVISIONS_TEMPLATE = """\
252 The revisions that were on this %(refname_type)s are still contained in
253 other references; therefore, this change does not discard any commits
254 from the repository.
258 NEW_REVISIONS_TEMPLATE = """\
259 The %(tot)s revisions listed above as "new" are entirely new to this
260 repository and will be described in separate emails. The revisions
261 listed as "adds" were already present in the repository and have only
262 been added to this reference.
267 TAG_CREATED_TEMPLATE = """\
268 at %(newrev_short)-9s (%(newrev_type)s)
272 TAG_UPDATED_TEMPLATE = """\
273 *** WARNING: tag %(short_refname)s was modified! ***
275 from %(oldrev_short)-9s (%(oldrev_type)s)
276 to %(newrev_short)-9s (%(newrev_type)s)
280 TAG_DELETED_TEMPLATE = """\
281 *** WARNING: tag %(short_refname)s was deleted! ***
286 # The template used in summary tables. It looks best if this uses the
287 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
288 BRIEF_SUMMARY_TEMPLATE = """\
289 %(action)10s %(rev_short)-9s %(text)s
293 NON_COMMIT_UPDATE_TEMPLATE = """\
294 This is an unusual reference change because the reference did not
295 refer to a commit either before or after the change. We do not know
296 how to provide full information about this reference change.
300 REVISION_HEADER_TEMPLATE = """\
301 Date: %(send_date)s
302 To: %(recipients)s
303 Cc: %(cc_recipients)s
304 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
305 MIME-Version: 1.0
306 Content-Type: text/%(contenttype)s; charset=%(charset)s
307 Content-Transfer-Encoding: 8bit
308 From: %(fromaddr)s
309 Reply-To: %(reply_to)s
310 In-Reply-To: %(reply_to_msgid)s
311 References: %(reply_to_msgid)s
312 X-Git-Host: %(fqdn)s
313 X-Git-Repo: %(repo_shortname)s
314 X-Git-Refname: %(refname)s
315 X-Git-Reftype: %(refname_type)s
316 X-Git-Rev: %(rev)s
317 X-Git-NotificationType: diff
318 X-Git-Multimail-Version: %(multimail_version)s
319 Auto-Submitted: auto-generated
322 REVISION_INTRO_TEMPLATE = """\
323 This is an automated email from the git hooks/post-receive script.
325 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
326 in repository %(repo_shortname)s.
330 LINK_TEXT_TEMPLATE = """\
331 View the commit online:
332 %(browse_url)s
336 LINK_HTML_TEMPLATE = """\
337 <p><a href="%(browse_url)s">View the commit online</a>.</p>
341 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
344 # Combined, meaning refchange+revision email (for single-commit additions)
345 COMBINED_HEADER_TEMPLATE = """\
346 Date: %(send_date)s
347 To: %(recipients)s
348 Subject: %(subject)s
349 MIME-Version: 1.0
350 Content-Type: text/%(contenttype)s; charset=%(charset)s
351 Content-Transfer-Encoding: 8bit
352 Message-ID: %(msgid)s
353 From: %(fromaddr)s
354 Reply-To: %(reply_to)s
355 X-Git-Host: %(fqdn)s
356 X-Git-Repo: %(repo_shortname)s
357 X-Git-Refname: %(refname)s
358 X-Git-Reftype: %(refname_type)s
359 X-Git-Oldrev: %(oldrev)s
360 X-Git-Newrev: %(newrev)s
361 X-Git-Rev: %(rev)s
362 X-Git-NotificationType: ref_changed_plus_diff
363 X-Git-Multimail-Version: %(multimail_version)s
364 Auto-Submitted: auto-generated
367 COMBINED_INTRO_TEMPLATE = """\
368 This is an automated email from the git hooks/post-receive script.
370 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
371 in repository %(repo_shortname)s.
375 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
378 class CommandError(Exception):
379 def __init__(self, cmd, retcode):
380 self.cmd = cmd
381 self.retcode = retcode
382 Exception.__init__(
383 self,
384 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
388 class ConfigurationException(Exception):
389 pass
392 # The "git" program (this could be changed to include a full path):
393 GIT_EXECUTABLE = 'git'
396 # How "git" should be invoked (including global arguments), as a list
397 # of words. This variable is usually initialized automatically by
398 # read_git_output() via choose_git_command(), but if a value is set
399 # here then it will be used unconditionally.
400 GIT_CMD = None
403 def choose_git_command():
404 """Decide how to invoke git, and record the choice in GIT_CMD."""
406 global GIT_CMD
408 if GIT_CMD is None:
409 try:
410 # Check to see whether the "-c" option is accepted (it was
411 # only added in Git 1.7.2). We don't actually use the
412 # output of "git --version", though if we needed more
413 # specific version information this would be the place to
414 # do it.
415 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
416 read_output(cmd)
417 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
418 except CommandError:
419 GIT_CMD = [GIT_EXECUTABLE]
422 def read_git_output(args, input=None, keepends=False, **kw):
423 """Read the output of a Git command."""
425 if GIT_CMD is None:
426 choose_git_command()
428 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
431 def read_output(cmd, input=None, keepends=False, **kw):
432 if input:
433 stdin = subprocess.PIPE
434 input = str_to_bytes(input)
435 else:
436 stdin = None
437 p = subprocess.Popen(
438 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
440 (out, err) = p.communicate(input)
441 out = bytes_to_str(out)
442 retcode = p.wait()
443 if retcode:
444 raise CommandError(cmd, retcode)
445 if not keepends:
446 out = out.rstrip('\n\r')
447 return out
450 def read_git_lines(args, keepends=False, **kw):
451 """Return the lines output by Git command.
453 Return as single lines, with newlines stripped off."""
455 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
458 def git_rev_list_ish(cmd, spec, args=None, **kw):
459 """Common functionality for invoking a 'git rev-list'-like command.
461 Parameters:
462 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
463 * spec is a list of revision arguments to pass to the named
464 command. If None, this function returns an empty list.
465 * args is a list of extra arguments passed to the named command.
466 * All other keyword arguments (if any) are passed to the
467 underlying read_git_lines() function.
469 Return the output of the Git command in the form of a list, one
470 entry per output line.
472 if spec is None:
473 return []
474 if args is None:
475 args = []
476 args = [cmd, '--stdin'] + args
477 spec_stdin = ''.join(s + '\n' for s in spec)
478 return read_git_lines(args, input=spec_stdin, **kw)
481 def git_rev_list(spec, **kw):
482 """Run 'git rev-list' with the given list of revision arguments.
484 See git_rev_list_ish() for parameter and return value
485 documentation.
487 return git_rev_list_ish('rev-list', spec, **kw)
490 def git_log(spec, **kw):
491 """Run 'git log' with the given list of revision arguments.
493 See git_rev_list_ish() for parameter and return value
494 documentation.
496 return git_rev_list_ish('log', spec, **kw)
499 def header_encode(text, header_name=None):
500 """Encode and line-wrap the value of an email header field."""
502 # Convert to unicode, if required.
503 if not isinstance(text, unicode):
504 text = unicode(text, 'utf-8')
506 if is_ascii(text):
507 charset = 'ascii'
508 else:
509 charset = 'utf-8'
511 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
514 def addr_header_encode(text, header_name=None):
515 """Encode and line-wrap the value of an email header field containing
516 email addresses."""
518 # Convert to unicode, if required.
519 if not isinstance(text, unicode):
520 text = unicode(text, 'utf-8')
522 text = ', '.join(
523 formataddr((header_encode(name), emailaddr))
524 for name, emailaddr in getaddresses([text])
527 if is_ascii(text):
528 charset = 'ascii'
529 else:
530 charset = 'utf-8'
532 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
535 class Config(object):
536 def __init__(self, section, git_config=None):
537 """Represent a section of the git configuration.
539 If git_config is specified, it is passed to "git config" in
540 the GIT_CONFIG environment variable, meaning that "git config"
541 will read the specified path rather than the Git default
542 config paths."""
544 self.section = section
545 if git_config:
546 self.env = os.environ.copy()
547 self.env['GIT_CONFIG'] = git_config
548 else:
549 self.env = None
551 @staticmethod
552 def _split(s):
553 """Split NUL-terminated values."""
555 words = s.split('\0')
556 assert words[-1] == ''
557 return words[:-1]
559 @staticmethod
560 def add_config_parameters(c):
561 """Add configuration parameters to Git.
563 c is either an str or a list of str, each element being of the
564 form 'var=val' or 'var', with the same syntax and meaning as
565 the argument of 'git -c var=val'.
567 if isinstance(c, str):
568 c = (c,)
569 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
570 if parameters:
571 parameters += ' '
572 # git expects GIT_CONFIG_PARAMETERS to be of the form
573 # "'name1=value1' 'name2=value2' 'name3=value3'"
574 # including everything inside the double quotes (but not the double
575 # quotes themselves). Spacing is critical. Also, if a value contains
576 # a literal single quote that quote must be represented using the
577 # four character sequence: '\''
578 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
579 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
581 def get(self, name, default=None):
582 try:
583 values = self._split(read_git_output(
584 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
585 env=self.env, keepends=True,
587 assert len(values) == 1
588 return values[0]
589 except CommandError:
590 return default
592 def get_bool(self, name, default=None):
593 try:
594 value = read_git_output(
595 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
596 env=self.env,
598 except CommandError:
599 return default
600 return value == 'true'
602 def get_all(self, name, default=None):
603 """Read a (possibly multivalued) setting from the configuration.
605 Return the result as a list of values, or default if the name
606 is unset."""
608 try:
609 return self._split(read_git_output(
610 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
611 env=self.env, keepends=True,
613 except CommandError:
614 t, e, traceback = sys.exc_info()
615 if e.retcode == 1:
616 # "the section or key is invalid"; i.e., there is no
617 # value for the specified key.
618 return default
619 else:
620 raise
622 def set(self, name, value):
623 read_git_output(
624 ['config', '%s.%s' % (self.section, name), value],
625 env=self.env,
628 def add(self, name, value):
629 read_git_output(
630 ['config', '--add', '%s.%s' % (self.section, name), value],
631 env=self.env,
634 def __contains__(self, name):
635 return self.get_all(name, default=None) is not None
637 # We don't use this method anymore internally, but keep it here in
638 # case somebody is calling it from their own code:
639 def has_key(self, name):
640 return name in self
642 def unset_all(self, name):
643 try:
644 read_git_output(
645 ['config', '--unset-all', '%s.%s' % (self.section, name)],
646 env=self.env,
648 except CommandError:
649 t, e, traceback = sys.exc_info()
650 if e.retcode == 5:
651 # The name doesn't exist, which is what we wanted anyway...
652 pass
653 else:
654 raise
656 def set_recipients(self, name, value):
657 self.unset_all(name)
658 for pair in getaddresses([value]):
659 self.add(name, formataddr(pair))
662 def generate_summaries(*log_args):
663 """Generate a brief summary for each revision requested.
665 log_args are strings that will be passed directly to "git log" as
666 revision selectors. Iterate over (sha1_short, subject) for each
667 commit specified by log_args (subject is the first line of the
668 commit message as a string without EOLs)."""
670 cmd = [
671 'log', '--abbrev', '--format=%h %s',
672 ] + list(log_args) + ['--']
673 for line in read_git_lines(cmd):
674 yield tuple(line.split(' ', 1))
677 def limit_lines(lines, max_lines):
678 for (index, line) in enumerate(lines):
679 if index < max_lines:
680 yield line
682 if index >= max_lines:
683 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
686 def limit_linelength(lines, max_linelength):
687 for line in lines:
688 # Don't forget that lines always include a trailing newline.
689 if len(line) > max_linelength + 1:
690 line = line[:max_linelength - 7] + ' [...]\n'
691 yield line
694 class CommitSet(object):
695 """A (constant) set of object names.
697 The set should be initialized with full SHA1 object names. The
698 __contains__() method returns True iff its argument is an
699 abbreviation of any the names in the set."""
701 def __init__(self, names):
702 self._names = sorted(names)
704 def __len__(self):
705 return len(self._names)
707 def __contains__(self, sha1_abbrev):
708 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
710 i = bisect.bisect_left(self._names, sha1_abbrev)
711 return i < len(self) and self._names[i].startswith(sha1_abbrev)
714 class GitObject(object):
715 def __init__(self, sha1, type=None):
716 if sha1 == ZEROS:
717 self.sha1 = self.type = self.commit_sha1 = None
718 else:
719 self.sha1 = sha1
720 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
722 if self.type == 'commit':
723 self.commit_sha1 = self.sha1
724 elif self.type == 'tag':
725 try:
726 self.commit_sha1 = read_git_output(
727 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
729 except CommandError:
730 # Cannot deref tag to determine commit_sha1
731 self.commit_sha1 = None
732 else:
733 self.commit_sha1 = None
735 self.short = read_git_output(['rev-parse', '--short', sha1])
737 def get_summary(self):
738 """Return (sha1_short, subject) for this commit."""
740 if not self.sha1:
741 raise ValueError('Empty commit has no summary')
743 return next(iter(generate_summaries('--no-walk', self.sha1)))
745 def __eq__(self, other):
746 return isinstance(other, GitObject) and self.sha1 == other.sha1
748 def __hash__(self):
749 return hash(self.sha1)
751 def __nonzero__(self):
752 return bool(self.sha1)
754 def __bool__(self):
755 """Python 2 backward compatibility"""
756 return self.__nonzero__()
758 def __str__(self):
759 return self.sha1 or ZEROS
762 class Change(object):
763 """A Change that has been made to the Git repository.
765 Abstract class from which both Revisions and ReferenceChanges are
766 derived. A Change knows how to generate a notification email
767 describing itself."""
769 def __init__(self, environment):
770 self.environment = environment
771 self._values = None
772 self._contains_html_diff = False
774 def _contains_diff(self):
775 # We do contain a diff, should it be rendered in HTML?
776 if self.environment.commit_email_format == "html":
777 self._contains_html_diff = True
779 def _compute_values(self):
780 """Return a dictionary {keyword: expansion} for this Change.
782 Derived classes overload this method to add more entries to
783 the return value. This method is used internally by
784 get_values(). The return value should always be a new
785 dictionary."""
787 values = self.environment.get_values()
788 fromaddr = self.environment.get_fromaddr(change=self)
789 if fromaddr is not None:
790 values['fromaddr'] = fromaddr
791 values['multimail_version'] = get_version()
792 return values
794 # Aliases usable in template strings. Tuple of pairs (destination,
795 # source).
796 VALUES_ALIAS = (
797 ("id", "newrev"),
800 def get_values(self, **extra_values):
801 """Return a dictionary {keyword: expansion} for this Change.
803 Return a dictionary mapping keywords to the values that they
804 should be expanded to for this Change (used when interpolating
805 template strings). If any keyword arguments are supplied, add
806 those to the return value as well. The return value is always
807 a new dictionary."""
809 if self._values is None:
810 self._values = self._compute_values()
812 values = self._values.copy()
813 if extra_values:
814 values.update(extra_values)
816 for alias, val in self.VALUES_ALIAS:
817 values[alias] = values[val]
818 return values
820 def expand(self, template, **extra_values):
821 """Expand template.
823 Expand the template (which should be a string) using string
824 interpolation of the values for this Change. If any keyword
825 arguments are provided, also include those in the keywords
826 available for interpolation."""
828 return template % self.get_values(**extra_values)
830 def expand_lines(self, template, html_escape_val=False, **extra_values):
831 """Break template into lines and expand each line."""
833 values = self.get_values(**extra_values)
834 if html_escape_val:
835 for k in values:
836 if is_string(values[k]):
837 values[k] = cgi.escape(values[k], True)
838 for line in template.splitlines(True):
839 yield line % values
841 def expand_header_lines(self, template, **extra_values):
842 """Break template into lines and expand each line as an RFC 2822 header.
844 Encode values and split up lines that are too long. Silently
845 skip lines that contain references to unknown variables."""
847 values = self.get_values(**extra_values)
848 if self._contains_html_diff:
849 self._content_type = 'html'
850 else:
851 self._content_type = 'plain'
852 values['contenttype'] = self._content_type
854 for line in template.splitlines():
855 (name, value) = line.split(': ', 1)
857 try:
858 value = value % values
859 except KeyError:
860 t, e, traceback = sys.exc_info()
861 if DEBUG:
862 self.environment.log_warning(
863 'Warning: unknown variable %r in the following line; line skipped:\n'
864 ' %s\n'
865 % (e.args[0], line,)
867 else:
868 if name.lower() in ADDR_HEADERS:
869 value = addr_header_encode(value, name)
870 else:
871 value = header_encode(value, name)
872 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
873 yield splitline
875 def generate_email_header(self):
876 """Generate the RFC 2822 email headers for this Change, a line at a time.
878 The output should not include the trailing blank line."""
880 raise NotImplementedError()
882 def generate_browse_link(self, base_url):
883 """Generate a link to an online repository browser."""
884 return iter(())
886 def generate_email_intro(self, html_escape_val=False):
887 """Generate the email intro for this Change, a line at a time.
889 The output will be used as the standard boilerplate at the top
890 of the email body."""
892 raise NotImplementedError()
894 def generate_email_body(self):
895 """Generate the main part of the email body, a line at a time.
897 The text in the body might be truncated after a specified
898 number of lines (see multimailhook.emailmaxlines)."""
900 raise NotImplementedError()
902 def generate_email_footer(self, html_escape_val):
903 """Generate the footer of the email, a line at a time.
905 The footer is always included, irrespective of
906 multimailhook.emailmaxlines."""
908 raise NotImplementedError()
910 def _wrap_for_html(self, lines):
911 """Wrap the lines in HTML <pre> tag when using HTML format.
913 Escape special HTML characters and add <pre> and </pre> tags around
914 the given lines if we should be generating HTML as indicated by
915 self._contains_html_diff being set to true.
917 if self._contains_html_diff:
918 yield "<pre style='margin:0'>\n"
920 for line in lines:
921 yield cgi.escape(line)
923 yield '</pre>\n'
924 else:
925 for line in lines:
926 yield line
928 def generate_email(self, push, body_filter=None, extra_header_values={}):
929 """Generate an email describing this change.
931 Iterate over the lines (including the header lines) of an
932 email describing this change. If body_filter is not None,
933 then use it to filter the lines that are intended for the
934 email body.
936 The extra_header_values field is received as a dict and not as
937 **kwargs, to allow passing other keyword arguments in the
938 future (e.g. passing extra values to generate_email_intro()"""
940 for line in self.generate_email_header(**extra_header_values):
941 yield line
942 yield '\n'
943 html_escape_val = (self.environment.html_in_intro and
944 self._contains_html_diff)
945 intro = self.generate_email_intro(html_escape_val)
946 if not self.environment.html_in_intro:
947 intro = self._wrap_for_html(intro)
948 for line in intro:
949 yield line
951 if self.environment.commitBrowseURL:
952 for line in self.generate_browse_link(self.environment.commitBrowseURL):
953 yield line
955 body = self.generate_email_body(push)
956 if body_filter is not None:
957 body = body_filter(body)
959 diff_started = False
960 if self._contains_html_diff:
961 # "white-space: pre" is the default, but we need to
962 # specify it again in case the message is viewed in a
963 # webmail which wraps it in an element setting white-space
964 # to something else (Zimbra does this and sets
965 # white-space: pre-line).
966 yield '<pre style="white-space: pre; background: #F8F8F8">'
967 for line in body:
968 if self._contains_html_diff:
969 # This is very, very naive. It would be much better to really
970 # parse the diff, i.e. look at how many lines do we have in
971 # the hunk headers instead of blindly highlighting everything
972 # that looks like it might be part of a diff.
973 bgcolor = ''
974 fgcolor = ''
975 if line.startswith('--- a/'):
976 diff_started = True
977 bgcolor = 'e0e0ff'
978 elif line.startswith('diff ') or line.startswith('index '):
979 diff_started = True
980 fgcolor = '808080'
981 elif diff_started:
982 if line.startswith('+++ '):
983 bgcolor = 'e0e0ff'
984 elif line.startswith('@@'):
985 bgcolor = 'e0e0e0'
986 elif line.startswith('+'):
987 bgcolor = 'e0ffe0'
988 elif line.startswith('-'):
989 bgcolor = 'ffe0e0'
990 elif line.startswith('commit '):
991 fgcolor = '808000'
992 elif line.startswith(' '):
993 fgcolor = '404040'
995 # Chop the trailing LF, we don't want it inside <pre>.
996 line = cgi.escape(line[:-1])
998 if bgcolor or fgcolor:
999 style = 'display:block; white-space:pre;'
1000 if bgcolor:
1001 style += 'background:#' + bgcolor + ';'
1002 if fgcolor:
1003 style += 'color:#' + fgcolor + ';'
1004 # Use a <span style='display:block> to color the
1005 # whole line. The newline must be inside the span
1006 # to display properly both in Firefox and in
1007 # text-based browser.
1008 line = "<span style='%s'>%s\n</span>" % (style, line)
1009 else:
1010 line = line + '\n'
1012 yield line
1013 if self._contains_html_diff:
1014 yield '</pre>'
1015 html_escape_val = (self.environment.html_in_footer and
1016 self._contains_html_diff)
1017 footer = self.generate_email_footer(html_escape_val)
1018 if not self.environment.html_in_footer:
1019 footer = self._wrap_for_html(footer)
1020 for line in footer:
1021 yield line
1023 def get_alt_fromaddr(self):
1024 return None
1027 class Revision(Change):
1028 """A Change consisting of a single git commit."""
1030 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1032 def __init__(self, reference_change, rev, num, tot):
1033 Change.__init__(self, reference_change.environment)
1034 self.reference_change = reference_change
1035 self.rev = rev
1036 self.change_type = self.reference_change.change_type
1037 self.refname = self.reference_change.refname
1038 self.num = num
1039 self.tot = tot
1040 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1041 self.recipients = self.environment.get_revision_recipients(self)
1043 self.cc_recipients = ''
1044 if self.environment.get_scancommitforcc():
1045 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1046 if self.cc_recipients:
1047 self.environment.log_msg(
1048 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
1050 def _cc_recipients(self):
1051 cc_recipients = []
1052 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1053 lines = message.strip().split('\n')
1054 for line in lines:
1055 m = re.match(self.CC_RE, line)
1056 if m:
1057 cc_recipients.append(m.group('to'))
1059 return cc_recipients
1061 def _compute_values(self):
1062 values = Change._compute_values(self)
1064 oneline = read_git_output(
1065 ['log', '--format=%s', '--no-walk', self.rev.sha1]
1068 values['rev'] = self.rev.sha1
1069 values['rev_short'] = self.rev.short
1070 values['change_type'] = self.change_type
1071 values['refname'] = self.refname
1072 values['newrev'] = self.rev.sha1
1073 values['short_refname'] = self.reference_change.short_refname
1074 values['refname_type'] = self.reference_change.refname_type
1075 values['reply_to_msgid'] = self.reference_change.msgid
1076 values['num'] = self.num
1077 values['tot'] = self.tot
1078 values['recipients'] = self.recipients
1079 if self.cc_recipients:
1080 values['cc_recipients'] = self.cc_recipients
1081 values['oneline'] = oneline
1082 values['author'] = self.author
1084 reply_to = self.environment.get_reply_to_commit(self)
1085 if reply_to:
1086 values['reply_to'] = reply_to
1088 return values
1090 def generate_email_header(self, **extra_values):
1091 for line in self.expand_header_lines(
1092 REVISION_HEADER_TEMPLATE, **extra_values
1094 yield line
1096 def generate_browse_link(self, base_url):
1097 if '%(' not in base_url:
1098 base_url += '%(id)s'
1099 url = "".join(self.expand_lines(base_url))
1100 if self._content_type == 'html':
1101 for line in self.expand_lines(LINK_HTML_TEMPLATE,
1102 html_escape_val=True,
1103 browse_url=url):
1104 yield line
1105 elif self._content_type == 'plain':
1106 for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1107 html_escape_val=False,
1108 browse_url=url):
1109 yield line
1110 else:
1111 raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1113 def generate_email_intro(self, html_escape_val=False):
1114 for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1115 html_escape_val=html_escape_val):
1116 yield line
1118 def generate_email_body(self, push):
1119 """Show this revision."""
1121 for line in read_git_lines(
1122 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1123 keepends=True,
1125 if line.startswith('Date: ') and self.environment.date_substitute:
1126 yield self.environment.date_substitute + line[len('Date: '):]
1127 else:
1128 yield line
1130 def generate_email_footer(self, html_escape_val):
1131 return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1132 html_escape_val=html_escape_val)
1134 def generate_email(self, push, body_filter=None, extra_header_values={}):
1135 self._contains_diff()
1136 return Change.generate_email(self, push, body_filter, extra_header_values)
1138 def get_alt_fromaddr(self):
1139 return self.environment.from_commit
1142 class ReferenceChange(Change):
1143 """A Change to a Git reference.
1145 An abstract class representing a create, update, or delete of a
1146 Git reference. Derived classes handle specific types of reference
1147 (e.g., tags vs. branches). These classes generate the main
1148 reference change email summarizing the reference change and
1149 whether it caused any any commits to be added or removed.
1151 ReferenceChange objects are usually created using the static
1152 create() method, which has the logic to decide which derived class
1153 to instantiate."""
1155 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1157 @staticmethod
1158 def create(environment, oldrev, newrev, refname):
1159 """Return a ReferenceChange object representing the change.
1161 Return an object that represents the type of change that is being
1162 made. oldrev and newrev should be SHA1s or ZEROS."""
1164 old = GitObject(oldrev)
1165 new = GitObject(newrev)
1166 rev = new or old
1168 # The revision type tells us what type the commit is, combined with
1169 # the location of the ref we can decide between
1170 # - working branch
1171 # - tracking branch
1172 # - unannotated tag
1173 # - annotated tag
1174 m = ReferenceChange.REF_RE.match(refname)
1175 if m:
1176 area = m.group('area')
1177 short_refname = m.group('shortname')
1178 else:
1179 area = ''
1180 short_refname = refname
1182 if rev.type == 'tag':
1183 # Annotated tag:
1184 klass = AnnotatedTagChange
1185 elif rev.type == 'commit':
1186 if area == 'tags':
1187 # Non-annotated tag:
1188 klass = NonAnnotatedTagChange
1189 elif area == 'heads':
1190 # Branch:
1191 klass = BranchChange
1192 elif area == 'remotes':
1193 # Tracking branch:
1194 environment.log_warning(
1195 '*** Push-update of tracking branch %r\n'
1196 '*** - incomplete email generated.\n'
1197 % (refname,)
1199 klass = OtherReferenceChange
1200 else:
1201 # Some other reference namespace:
1202 environment.log_warning(
1203 '*** Push-update of strange reference %r\n'
1204 '*** - incomplete email generated.\n'
1205 % (refname,)
1207 klass = OtherReferenceChange
1208 else:
1209 # Anything else (is there anything else?)
1210 environment.log_warning(
1211 '*** Unknown type of update to %r (%s)\n'
1212 '*** - incomplete email generated.\n'
1213 % (refname, rev.type,)
1215 klass = OtherReferenceChange
1217 return klass(
1218 environment,
1219 refname=refname, short_refname=short_refname,
1220 old=old, new=new, rev=rev,
1223 def __init__(self, environment, refname, short_refname, old, new, rev):
1224 Change.__init__(self, environment)
1225 self.change_type = {
1226 (False, True): 'create',
1227 (True, True): 'update',
1228 (True, False): 'delete',
1229 }[bool(old), bool(new)]
1230 self.refname = refname
1231 self.short_refname = short_refname
1232 self.old = old
1233 self.new = new
1234 self.rev = rev
1235 self.msgid = make_msgid()
1236 self.diffopts = environment.diffopts
1237 self.graphopts = environment.graphopts
1238 self.logopts = environment.logopts
1239 self.commitlogopts = environment.commitlogopts
1240 self.showgraph = environment.refchange_showgraph
1241 self.showlog = environment.refchange_showlog
1243 self.header_template = REFCHANGE_HEADER_TEMPLATE
1244 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1245 self.footer_template = FOOTER_TEMPLATE
1247 def _compute_values(self):
1248 values = Change._compute_values(self)
1250 values['change_type'] = self.change_type
1251 values['refname_type'] = self.refname_type
1252 values['refname'] = self.refname
1253 values['short_refname'] = self.short_refname
1254 values['msgid'] = self.msgid
1255 values['recipients'] = self.recipients
1256 values['oldrev'] = str(self.old)
1257 values['oldrev_short'] = self.old.short
1258 values['newrev'] = str(self.new)
1259 values['newrev_short'] = self.new.short
1261 if self.old:
1262 values['oldrev_type'] = self.old.type
1263 if self.new:
1264 values['newrev_type'] = self.new.type
1266 reply_to = self.environment.get_reply_to_refchange(self)
1267 if reply_to:
1268 values['reply_to'] = reply_to
1270 return values
1272 def send_single_combined_email(self, known_added_sha1s):
1273 """Determine if a combined refchange/revision email should be sent
1275 If there is only a single new (non-merge) commit added by a
1276 change, it is useful to combine the ReferenceChange and
1277 Revision emails into one. In such a case, return the single
1278 revision; otherwise, return None.
1280 This method is overridden in BranchChange."""
1282 return None
1284 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1285 """Generate an email describing this change AND specified revision.
1287 Iterate over the lines (including the header lines) of an
1288 email describing this change. If body_filter is not None,
1289 then use it to filter the lines that are intended for the
1290 email body.
1292 The extra_header_values field is received as a dict and not as
1293 **kwargs, to allow passing other keyword arguments in the
1294 future (e.g. passing extra values to generate_email_intro()
1296 This method is overridden in BranchChange."""
1298 raise NotImplementedError
1300 def get_subject(self):
1301 template = {
1302 'create': REF_CREATED_SUBJECT_TEMPLATE,
1303 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1304 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1305 }[self.change_type]
1306 return self.expand(template)
1308 def generate_email_header(self, **extra_values):
1309 if 'subject' not in extra_values:
1310 extra_values['subject'] = self.get_subject()
1312 for line in self.expand_header_lines(
1313 self.header_template, **extra_values
1315 yield line
1317 def generate_email_intro(self, html_escape_val=False):
1318 for line in self.expand_lines(self.intro_template,
1319 html_escape_val=html_escape_val):
1320 yield line
1322 def generate_email_body(self, push):
1323 """Call the appropriate body-generation routine.
1325 Call one of generate_create_summary() /
1326 generate_update_summary() / generate_delete_summary()."""
1328 change_summary = {
1329 'create': self.generate_create_summary,
1330 'delete': self.generate_delete_summary,
1331 'update': self.generate_update_summary,
1332 }[self.change_type](push)
1333 for line in change_summary:
1334 yield line
1336 for line in self.generate_revision_change_summary(push):
1337 yield line
1339 def generate_email_footer(self, html_escape_val):
1340 return self.expand_lines(self.footer_template,
1341 html_escape_val=html_escape_val)
1343 def generate_revision_change_graph(self, push):
1344 if self.showgraph:
1345 args = ['--graph'] + self.graphopts
1346 for newold in ('new', 'old'):
1347 has_newold = False
1348 spec = push.get_commits_spec(newold, self)
1349 for line in git_log(spec, args=args, keepends=True):
1350 if not has_newold:
1351 has_newold = True
1352 yield '\n'
1353 yield 'Graph of %s commits:\n\n' % (
1354 {'new': 'new', 'old': 'discarded'}[newold],)
1355 yield ' ' + line
1356 if has_newold:
1357 yield '\n'
1359 def generate_revision_change_log(self, new_commits_list):
1360 if self.showlog:
1361 yield '\n'
1362 yield 'Detailed log of new commits:\n\n'
1363 for line in read_git_lines(
1364 ['log', '--no-walk'] +
1365 self.logopts +
1366 new_commits_list +
1367 ['--'],
1368 keepends=True,
1370 yield line
1372 def generate_new_revision_summary(self, tot, new_commits_list, push):
1373 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1374 yield line
1375 for line in self.generate_revision_change_graph(push):
1376 yield line
1377 for line in self.generate_revision_change_log(new_commits_list):
1378 yield line
1380 def generate_revision_change_summary(self, push):
1381 """Generate a summary of the revisions added/removed by this change."""
1383 if self.new.commit_sha1 and not self.old.commit_sha1:
1384 # A new reference was created. List the new revisions
1385 # brought by the new reference (i.e., those revisions that
1386 # were not in the repository before this reference
1387 # change).
1388 sha1s = list(push.get_new_commits(self))
1389 sha1s.reverse()
1390 tot = len(sha1s)
1391 new_revisions = [
1392 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1393 for (i, sha1) in enumerate(sha1s)
1396 if new_revisions:
1397 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1398 yield '\n'
1399 for r in new_revisions:
1400 (sha1, subject) = r.rev.get_summary()
1401 yield r.expand(
1402 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1404 yield '\n'
1405 for line in self.generate_new_revision_summary(
1406 tot, [r.rev.sha1 for r in new_revisions], push):
1407 yield line
1408 else:
1409 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1410 yield line
1412 elif self.new.commit_sha1 and self.old.commit_sha1:
1413 # A reference was changed to point at a different commit.
1414 # List the revisions that were removed and/or added *from
1415 # that reference* by this reference change, along with a
1416 # diff between the trees for its old and new values.
1418 # List of the revisions that were added to the branch by
1419 # this update. Note this list can include revisions that
1420 # have already had notification emails; we want such
1421 # revisions in the summary even though we will not send
1422 # new notification emails for them.
1423 adds = list(generate_summaries(
1424 '--topo-order', '--reverse', '%s..%s'
1425 % (self.old.commit_sha1, self.new.commit_sha1,)
1428 # List of the revisions that were removed from the branch
1429 # by this update. This will be empty except for
1430 # non-fast-forward updates.
1431 discards = list(generate_summaries(
1432 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1435 if adds:
1436 new_commits_list = push.get_new_commits(self)
1437 else:
1438 new_commits_list = []
1439 new_commits = CommitSet(new_commits_list)
1441 if discards:
1442 discarded_commits = CommitSet(push.get_discarded_commits(self))
1443 else:
1444 discarded_commits = CommitSet([])
1446 if discards and adds:
1447 for (sha1, subject) in discards:
1448 if sha1 in discarded_commits:
1449 action = 'discards'
1450 else:
1451 action = 'omits'
1452 yield self.expand(
1453 BRIEF_SUMMARY_TEMPLATE, action=action,
1454 rev_short=sha1, text=subject,
1456 for (sha1, subject) in adds:
1457 if sha1 in new_commits:
1458 action = 'new'
1459 else:
1460 action = 'adds'
1461 yield self.expand(
1462 BRIEF_SUMMARY_TEMPLATE, action=action,
1463 rev_short=sha1, text=subject,
1465 yield '\n'
1466 for line in self.expand_lines(NON_FF_TEMPLATE):
1467 yield line
1469 elif discards:
1470 for (sha1, subject) in discards:
1471 if sha1 in discarded_commits:
1472 action = 'discards'
1473 else:
1474 action = 'omits'
1475 yield self.expand(
1476 BRIEF_SUMMARY_TEMPLATE, action=action,
1477 rev_short=sha1, text=subject,
1479 yield '\n'
1480 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1481 yield line
1483 elif adds:
1484 (sha1, subject) = self.old.get_summary()
1485 yield self.expand(
1486 BRIEF_SUMMARY_TEMPLATE, action='from',
1487 rev_short=sha1, text=subject,
1489 for (sha1, subject) in adds:
1490 if sha1 in new_commits:
1491 action = 'new'
1492 else:
1493 action = 'adds'
1494 yield self.expand(
1495 BRIEF_SUMMARY_TEMPLATE, action=action,
1496 rev_short=sha1, text=subject,
1499 yield '\n'
1501 if new_commits:
1502 for line in self.generate_new_revision_summary(
1503 len(new_commits), new_commits_list, push):
1504 yield line
1505 else:
1506 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1507 yield line
1508 for line in self.generate_revision_change_graph(push):
1509 yield line
1511 # The diffstat is shown from the old revision to the new
1512 # revision. This is to show the truth of what happened in
1513 # this change. There's no point showing the stat from the
1514 # base to the new revision because the base is effectively a
1515 # random revision at this point - the user will be interested
1516 # in what this revision changed - including the undoing of
1517 # previous revisions in the case of non-fast-forward updates.
1518 yield '\n'
1519 yield 'Summary of changes:\n'
1520 for line in read_git_lines(
1521 ['diff-tree'] +
1522 self.diffopts +
1523 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1524 keepends=True,
1526 yield line
1528 elif self.old.commit_sha1 and not self.new.commit_sha1:
1529 # A reference was deleted. List the revisions that were
1530 # removed from the repository by this reference change.
1532 sha1s = list(push.get_discarded_commits(self))
1533 tot = len(sha1s)
1534 discarded_revisions = [
1535 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1536 for (i, sha1) in enumerate(sha1s)
1539 if discarded_revisions:
1540 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1541 yield line
1542 yield '\n'
1543 for r in discarded_revisions:
1544 (sha1, subject) = r.rev.get_summary()
1545 yield r.expand(
1546 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1548 for line in self.generate_revision_change_graph(push):
1549 yield line
1550 else:
1551 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1552 yield line
1554 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1555 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1556 yield line
1558 def generate_create_summary(self, push):
1559 """Called for the creation of a reference."""
1561 # This is a new reference and so oldrev is not valid
1562 (sha1, subject) = self.new.get_summary()
1563 yield self.expand(
1564 BRIEF_SUMMARY_TEMPLATE, action='at',
1565 rev_short=sha1, text=subject,
1567 yield '\n'
1569 def generate_update_summary(self, push):
1570 """Called for the change of a pre-existing branch."""
1572 return iter([])
1574 def generate_delete_summary(self, push):
1575 """Called for the deletion of any type of reference."""
1577 (sha1, subject) = self.old.get_summary()
1578 yield self.expand(
1579 BRIEF_SUMMARY_TEMPLATE, action='was',
1580 rev_short=sha1, text=subject,
1582 yield '\n'
1584 def get_alt_fromaddr(self):
1585 return self.environment.from_refchange
1588 class BranchChange(ReferenceChange):
1589 refname_type = 'branch'
1591 def __init__(self, environment, refname, short_refname, old, new, rev):
1592 ReferenceChange.__init__(
1593 self, environment,
1594 refname=refname, short_refname=short_refname,
1595 old=old, new=new, rev=rev,
1597 self.recipients = environment.get_refchange_recipients(self)
1598 self._single_revision = None
1600 def send_single_combined_email(self, known_added_sha1s):
1601 if not self.environment.combine_when_single_commit:
1602 return None
1604 # In the sadly-all-too-frequent usecase of people pushing only
1605 # one of their commits at a time to a repository, users feel
1606 # the reference change summary emails are noise rather than
1607 # important signal. This is because, in this particular
1608 # usecase, there is a reference change summary email for each
1609 # new commit, and all these summaries do is point out that
1610 # there is one new commit (which can readily be inferred by
1611 # the existence of the individual revision email that is also
1612 # sent). In such cases, our users prefer there to be a combined
1613 # reference change summary/new revision email.
1615 # So, if the change is an update and it doesn't discard any
1616 # commits, and it adds exactly one non-merge commit (gerrit
1617 # forces a workflow where every commit is individually merged
1618 # and the git-multimail hook fired off for just this one
1619 # change), then we send a combined refchange/revision email.
1620 try:
1621 # If this change is a reference update that doesn't discard
1622 # any commits...
1623 if self.change_type != 'update':
1624 return None
1626 if read_git_lines(
1627 ['merge-base', self.old.sha1, self.new.sha1]
1628 ) != [self.old.sha1]:
1629 return None
1631 # Check if this update introduced exactly one non-merge
1632 # commit:
1634 def split_line(line):
1635 """Split line into (sha1, [parent,...])."""
1637 words = line.split()
1638 return (words[0], words[1:])
1640 # Get the new commits introduced by the push as a list of
1641 # (sha1, [parent,...])
1642 new_commits = [
1643 split_line(line)
1644 for line in read_git_lines(
1646 'log', '-3', '--format=%H %P',
1647 '%s..%s' % (self.old.sha1, self.new.sha1),
1652 if not new_commits:
1653 return None
1655 # If the newest commit is a merge, save it for a later check
1656 # but otherwise ignore it
1657 merge = None
1658 tot = len(new_commits)
1659 if len(new_commits[0][1]) > 1:
1660 merge = new_commits[0][0]
1661 del new_commits[0]
1663 # Our primary check: we can't combine if more than one commit
1664 # is introduced. We also currently only combine if the new
1665 # commit is a non-merge commit, though it may make sense to
1666 # combine if it is a merge as well.
1667 if not (
1668 len(new_commits) == 1 and
1669 len(new_commits[0][1]) == 1 and
1670 new_commits[0][0] in known_added_sha1s
1672 return None
1674 # We do not want to combine revision and refchange emails if
1675 # those go to separate locations.
1676 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1677 if rev.recipients != self.recipients:
1678 return None
1680 # We ignored the newest commit if it was just a merge of the one
1681 # commit being introduced. But we don't want to ignore that
1682 # merge commit it it involved conflict resolutions. Check that.
1683 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1684 return None
1686 # We can combine the refchange and one new revision emails
1687 # into one. Return the Revision that a combined email should
1688 # be sent about.
1689 return rev
1690 except CommandError:
1691 # Cannot determine number of commits in old..new or new..old;
1692 # don't combine reference/revision emails:
1693 return None
1695 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1696 values = revision.get_values()
1697 if extra_header_values:
1698 values.update(extra_header_values)
1699 if 'subject' not in extra_header_values:
1700 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1702 self._single_revision = revision
1703 self._contains_diff()
1704 self.header_template = COMBINED_HEADER_TEMPLATE
1705 self.intro_template = COMBINED_INTRO_TEMPLATE
1706 self.footer_template = COMBINED_FOOTER_TEMPLATE
1708 def revision_gen_link(base_url):
1709 # revision is used only to generate the body, and
1710 # _content_type is set while generating headers. Get it
1711 # from the BranchChange object.
1712 revision._content_type = self._content_type
1713 return revision.generate_browse_link(base_url)
1714 self.generate_browse_link = revision_gen_link
1715 for line in self.generate_email(push, body_filter, values):
1716 yield line
1718 def generate_email_body(self, push):
1719 '''Call the appropriate body generation routine.
1721 If this is a combined refchange/revision email, the special logic
1722 for handling this combined email comes from this function. For
1723 other cases, we just use the normal handling.'''
1725 # If self._single_revision isn't set; don't override
1726 if not self._single_revision:
1727 for line in super(BranchChange, self).generate_email_body(push):
1728 yield line
1729 return
1731 # This is a combined refchange/revision email; we first provide
1732 # some info from the refchange portion, and then call the revision
1733 # generate_email_body function to handle the revision portion.
1734 adds = list(generate_summaries(
1735 '--topo-order', '--reverse', '%s..%s'
1736 % (self.old.commit_sha1, self.new.commit_sha1,)
1739 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1740 for (sha1, subject) in adds:
1741 yield self.expand(
1742 BRIEF_SUMMARY_TEMPLATE, action='new',
1743 rev_short=sha1, text=subject,
1746 yield self._single_revision.rev.short + " is described below\n"
1747 yield '\n'
1749 for line in self._single_revision.generate_email_body(push):
1750 yield line
1753 class AnnotatedTagChange(ReferenceChange):
1754 refname_type = 'annotated tag'
1756 def __init__(self, environment, refname, short_refname, old, new, rev):
1757 ReferenceChange.__init__(
1758 self, environment,
1759 refname=refname, short_refname=short_refname,
1760 old=old, new=new, rev=rev,
1762 self.recipients = environment.get_announce_recipients(self)
1763 self.show_shortlog = environment.announce_show_shortlog
1765 ANNOTATED_TAG_FORMAT = (
1766 '%(*objectname)\n'
1767 '%(*objecttype)\n'
1768 '%(taggername)\n'
1769 '%(taggerdate)'
1772 def describe_tag(self, push):
1773 """Describe the new value of an annotated tag."""
1775 # Use git for-each-ref to pull out the individual fields from
1776 # the tag
1777 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1778 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1781 yield self.expand(
1782 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1783 rev_short=tagobject, text='(%s)' % (tagtype,),
1785 if tagtype == 'commit':
1786 # If the tagged object is a commit, then we assume this is a
1787 # release, and so we calculate which tag this tag is
1788 # replacing
1789 try:
1790 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1791 except CommandError:
1792 prevtag = None
1793 if prevtag:
1794 yield ' replaces %s\n' % (prevtag,)
1795 else:
1796 prevtag = None
1797 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1799 yield ' tagged by %s\n' % (tagger,)
1800 yield ' on %s\n' % (tagged,)
1801 yield '\n'
1803 # Show the content of the tag message; this might contain a
1804 # change log or release notes so is worth displaying.
1805 yield LOGBEGIN
1806 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1807 contents = contents[contents.index('\n') + 1:]
1808 if contents and contents[-1][-1:] != '\n':
1809 contents.append('\n')
1810 for line in contents:
1811 yield line
1813 if self.show_shortlog and tagtype == 'commit':
1814 # Only commit tags make sense to have rev-list operations
1815 # performed on them
1816 yield '\n'
1817 if prevtag:
1818 # Show changes since the previous release
1819 revlist = read_git_output(
1820 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1821 keepends=True,
1823 else:
1824 # No previous tag, show all the changes since time
1825 # began
1826 revlist = read_git_output(
1827 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1828 keepends=True,
1830 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1831 yield line
1833 yield LOGEND
1834 yield '\n'
1836 def generate_create_summary(self, push):
1837 """Called for the creation of an annotated tag."""
1839 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1840 yield line
1842 for line in self.describe_tag(push):
1843 yield line
1845 def generate_update_summary(self, push):
1846 """Called for the update of an annotated tag.
1848 This is probably a rare event and may not even be allowed."""
1850 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1851 yield line
1853 for line in self.describe_tag(push):
1854 yield line
1856 def generate_delete_summary(self, push):
1857 """Called when a non-annotated reference is updated."""
1859 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1860 yield line
1862 yield self.expand(' tag was %(oldrev_short)s\n')
1863 yield '\n'
1866 class NonAnnotatedTagChange(ReferenceChange):
1867 refname_type = 'tag'
1869 def __init__(self, environment, refname, short_refname, old, new, rev):
1870 ReferenceChange.__init__(
1871 self, environment,
1872 refname=refname, short_refname=short_refname,
1873 old=old, new=new, rev=rev,
1875 self.recipients = environment.get_refchange_recipients(self)
1877 def generate_create_summary(self, push):
1878 """Called for the creation of an annotated tag."""
1880 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1881 yield line
1883 def generate_update_summary(self, push):
1884 """Called when a non-annotated reference is updated."""
1886 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1887 yield line
1889 def generate_delete_summary(self, push):
1890 """Called when a non-annotated reference is updated."""
1892 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1893 yield line
1895 for line in ReferenceChange.generate_delete_summary(self, push):
1896 yield line
1899 class OtherReferenceChange(ReferenceChange):
1900 refname_type = 'reference'
1902 def __init__(self, environment, refname, short_refname, old, new, rev):
1903 # We use the full refname as short_refname, because otherwise
1904 # the full name of the reference would not be obvious from the
1905 # text of the email.
1906 ReferenceChange.__init__(
1907 self, environment,
1908 refname=refname, short_refname=refname,
1909 old=old, new=new, rev=rev,
1911 self.recipients = environment.get_refchange_recipients(self)
1914 class Mailer(object):
1915 """An object that can send emails."""
1917 def send(self, lines, to_addrs):
1918 """Send an email consisting of lines.
1920 lines must be an iterable over the lines constituting the
1921 header and body of the email. to_addrs is a list of recipient
1922 addresses (can be needed even if lines already contains a
1923 "To:" field). It can be either a string (comma-separated list
1924 of email addresses) or a Python list of individual email
1925 addresses.
1929 raise NotImplementedError()
1932 class SendMailer(Mailer):
1933 """Send emails using 'sendmail -oi -t'."""
1935 SENDMAIL_CANDIDATES = [
1936 '/usr/sbin/sendmail',
1937 '/usr/lib/sendmail',
1940 @staticmethod
1941 def find_sendmail():
1942 for path in SendMailer.SENDMAIL_CANDIDATES:
1943 if os.access(path, os.X_OK):
1944 return path
1945 else:
1946 raise ConfigurationException(
1947 'No sendmail executable found. '
1948 'Try setting multimailhook.sendmailCommand.'
1951 def __init__(self, command=None, envelopesender=None):
1952 """Construct a SendMailer instance.
1954 command should be the command and arguments used to invoke
1955 sendmail, as a list of strings. If an envelopesender is
1956 provided, it will also be passed to the command, via '-f
1957 envelopesender'."""
1959 if command:
1960 self.command = command[:]
1961 else:
1962 self.command = [self.find_sendmail(), '-oi', '-t']
1964 if envelopesender:
1965 self.command.extend(['-f', envelopesender])
1967 def send(self, lines, to_addrs):
1968 try:
1969 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1970 except OSError:
1971 sys.stderr.write(
1972 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
1973 '*** %s\n' % sys.exc_info()[1] +
1974 '*** Try setting multimailhook.mailer to "smtp"\n' +
1975 '*** to send emails without using the sendmail command.\n'
1977 sys.exit(1)
1978 try:
1979 lines = (str_to_bytes(line) for line in lines)
1980 p.stdin.writelines(lines)
1981 except Exception:
1982 sys.stderr.write(
1983 '*** Error while generating commit email\n'
1984 '*** - mail sending aborted.\n'
1986 try:
1987 # subprocess.terminate() is not available in Python 2.4
1988 p.terminate()
1989 except AttributeError:
1990 pass
1991 raise
1992 else:
1993 p.stdin.close()
1994 retcode = p.wait()
1995 if retcode:
1996 raise CommandError(self.command, retcode)
1999 class SMTPMailer(Mailer):
2000 """Send emails using Python's smtplib."""
2002 def __init__(self, envelopesender, smtpserver,
2003 smtpservertimeout=10.0, smtpserverdebuglevel=0,
2004 smtpencryption='none',
2005 smtpuser='', smtppass='',
2006 smtpcacerts=''
2008 if not envelopesender:
2009 sys.stderr.write(
2010 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2011 'please set either multimailhook.envelopeSender or user.email\n'
2013 sys.exit(1)
2014 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2015 raise ConfigurationException(
2016 'Cannot use SMTPMailer with security option ssl '
2017 'without options username and password.'
2019 self.envelopesender = envelopesender
2020 self.smtpserver = smtpserver
2021 self.smtpservertimeout = smtpservertimeout
2022 self.smtpserverdebuglevel = smtpserverdebuglevel
2023 self.security = smtpencryption
2024 self.username = smtpuser
2025 self.password = smtppass
2026 self.smtpcacerts = smtpcacerts
2027 try:
2028 def call(klass, server, timeout):
2029 try:
2030 return klass(server, timeout=timeout)
2031 except TypeError:
2032 # Old Python versions do not have timeout= argument.
2033 return klass(server)
2034 if self.security == 'none':
2035 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2036 elif self.security == 'ssl':
2037 if self.smtpcacerts:
2038 raise smtplib.SMTPException(
2039 "Checking certificate is not supported for ssl, prefer starttls"
2041 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2042 elif self.security == 'tls':
2043 if 'ssl' not in sys.modules:
2044 sys.stderr.write(
2045 '*** Your Python version does not have the ssl library installed\n'
2046 '*** smtpEncryption=tls is not available.\n'
2047 '*** Either upgrade Python to 2.6 or later\n'
2048 ' or use git_multimail.py version 1.2.\n')
2049 if ':' not in self.smtpserver:
2050 self.smtpserver += ':587' # default port for TLS
2051 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2052 # start: ehlo + starttls
2053 # equivalent to
2054 # self.smtp.ehlo()
2055 # self.smtp.starttls()
2056 # with acces to the ssl layer
2057 self.smtp.ehlo()
2058 if not self.smtp.has_extn("starttls"):
2059 raise smtplib.SMTPException("STARTTLS extension not supported by server")
2060 resp, reply = self.smtp.docmd("STARTTLS")
2061 if resp != 220:
2062 raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2063 if self.smtpcacerts:
2064 self.smtp.sock = ssl.wrap_socket(
2065 self.smtp.sock,
2066 ca_certs=self.smtpcacerts,
2067 cert_reqs=ssl.CERT_REQUIRED
2069 else:
2070 self.smtp.sock = ssl.wrap_socket(
2071 self.smtp.sock,
2072 cert_reqs=ssl.CERT_NONE
2074 sys.stderr.write(
2075 '*** Warning, the server certificat is not verified (smtp) ***\n'
2076 '*** set the option smtpCACerts ***\n'
2078 if not hasattr(self.smtp.sock, "read"):
2079 # using httplib.FakeSocket with Python 2.5.x or earlier
2080 self.smtp.sock.read = self.smtp.sock.recv
2081 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2082 self.smtp.helo_resp = None
2083 self.smtp.ehlo_resp = None
2084 self.smtp.esmtp_features = {}
2085 self.smtp.does_esmtp = 0
2086 # end: ehlo + starttls
2087 self.smtp.ehlo()
2088 else:
2089 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2090 sys.exit(1)
2091 if self.smtpserverdebuglevel > 0:
2092 sys.stdout.write(
2093 "*** Setting debug on for SMTP server connection (%s) ***\n"
2094 % self.smtpserverdebuglevel)
2095 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2096 except Exception:
2097 sys.stderr.write(
2098 '*** Error establishing SMTP connection to %s ***\n'
2099 % self.smtpserver)
2100 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2101 sys.exit(1)
2103 def __del__(self):
2104 if hasattr(self, 'smtp'):
2105 self.smtp.quit()
2106 del self.smtp
2108 def send(self, lines, to_addrs):
2109 try:
2110 if self.username or self.password:
2111 self.smtp.login(self.username, self.password)
2112 msg = ''.join(lines)
2113 # turn comma-separated list into Python list if needed.
2114 if is_string(to_addrs):
2115 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2116 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2117 except smtplib.SMTPResponseException:
2118 sys.stderr.write('*** Error sending email ***\n')
2119 err = sys.exc_info()[1]
2120 sys.stderr.write('*** Error %d: %s\n' % (err.smtp_code,
2121 bytes_to_str(err.smtp_error)))
2122 try:
2123 smtp = self.smtp
2124 # delete the field before quit() so that in case of
2125 # error, self.smtp is deleted anyway.
2126 del self.smtp
2127 smtp.quit()
2128 except:
2129 sys.stderr.write('*** Error closing the SMTP connection ***\n')
2130 sys.stderr.write('*** Exiting anyway ... ***\n')
2131 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2132 sys.exit(1)
2135 class OutputMailer(Mailer):
2136 """Write emails to an output stream, bracketed by lines of '=' characters.
2138 This is intended for debugging purposes."""
2140 SEPARATOR = '=' * 75 + '\n'
2142 def __init__(self, f):
2143 self.f = f
2145 def send(self, lines, to_addrs):
2146 write_str(self.f, self.SEPARATOR)
2147 for line in lines:
2148 write_str(self.f, line)
2149 write_str(self.f, self.SEPARATOR)
2152 def get_git_dir():
2153 """Determine GIT_DIR.
2155 Determine GIT_DIR either from the GIT_DIR environment variable or
2156 from the working directory, using Git's usual rules."""
2158 try:
2159 return read_git_output(['rev-parse', '--git-dir'])
2160 except CommandError:
2161 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2162 sys.exit(1)
2165 class Environment(object):
2166 """Describes the environment in which the push is occurring.
2168 An Environment object encapsulates information about the local
2169 environment. For example, it knows how to determine:
2171 * the name of the repository to which the push occurred
2173 * what user did the push
2175 * what users want to be informed about various types of changes.
2177 An Environment object is expected to have the following methods:
2179 get_repo_shortname()
2181 Return a short name for the repository, for display
2182 purposes.
2184 get_repo_path()
2186 Return the absolute path to the Git repository.
2188 get_emailprefix()
2190 Return a string that will be prefixed to every email's
2191 subject.
2193 get_pusher()
2195 Return the username of the person who pushed the changes.
2196 This value is used in the email body to indicate who
2197 pushed the change.
2199 get_pusher_email() (may return None)
2201 Return the email address of the person who pushed the
2202 changes. The value should be a single RFC 2822 email
2203 address as a string; e.g., "Joe User <user@example.com>"
2204 if available, otherwise "user@example.com". If set, the
2205 value is used as the Reply-To address for refchange
2206 emails. If it is impossible to determine the pusher's
2207 email, this attribute should be set to None (in which case
2208 no Reply-To header will be output).
2210 get_sender()
2212 Return the address to be used as the 'From' email address
2213 in the email envelope.
2215 get_fromaddr(change=None)
2217 Return the 'From' email address used in the email 'From:'
2218 headers. If the change is known when this function is
2219 called, it is passed in as the 'change' parameter. (May
2220 be a full RFC 2822 email address like 'Joe User
2221 <user@example.com>'.)
2223 get_administrator()
2225 Return the name and/or email of the repository
2226 administrator. This value is used in the footer as the
2227 person to whom requests to be removed from the
2228 notification list should be sent. Ideally, it should
2229 include a valid email address.
2231 get_reply_to_refchange()
2232 get_reply_to_commit()
2234 Return the address to use in the email "Reply-To" header,
2235 as a string. These can be an RFC 2822 email address, or
2236 None to omit the "Reply-To" header.
2237 get_reply_to_refchange() is used for refchange emails;
2238 get_reply_to_commit() is used for individual commit
2239 emails.
2241 get_ref_filter_regex()
2243 Return a tuple -- a compiled regex, and a boolean indicating
2244 whether the regex picks refs to include (if False, the regex
2245 matches on refs to exclude).
2247 get_default_ref_ignore_regex()
2249 Return a regex that should be ignored for both what emails
2250 to send and when computing what commits are considered new
2251 to the repository. Default is "^refs/notes/".
2253 They should also define the following attributes:
2255 announce_show_shortlog (bool)
2257 True iff announce emails should include a shortlog.
2259 commit_email_format (string)
2261 If "html", generate commit emails in HTML instead of plain text
2262 used by default.
2264 html_in_intro (bool)
2265 html_in_footer (bool)
2267 When generating HTML emails, the introduction (respectively,
2268 the footer) will be HTML-escaped iff html_in_intro (respectively,
2269 the footer) is true. When false, only the values used to expand
2270 the template are escaped.
2272 refchange_showgraph (bool)
2274 True iff refchanges emails should include a detailed graph.
2276 refchange_showlog (bool)
2278 True iff refchanges emails should include a detailed log.
2280 diffopts (list of strings)
2282 The options that should be passed to 'git diff' for the
2283 summary email. The value should be a list of strings
2284 representing words to be passed to the command.
2286 graphopts (list of strings)
2288 Analogous to diffopts, but contains options passed to
2289 'git log --graph' when generating the detailed graph for
2290 a set of commits (see refchange_showgraph)
2292 logopts (list of strings)
2294 Analogous to diffopts, but contains options passed to
2295 'git log' when generating the detailed log for a set of
2296 commits (see refchange_showlog)
2298 commitlogopts (list of strings)
2300 The options that should be passed to 'git log' for each
2301 commit mail. The value should be a list of strings
2302 representing words to be passed to the command.
2304 date_substitute (string)
2306 String to be used in substitution for 'Date:' at start of
2307 line in the output of 'git log'.
2309 quiet (bool)
2310 On success do not write to stderr
2312 stdout (bool)
2313 Write email to stdout rather than emailing. Useful for debugging
2315 combine_when_single_commit (bool)
2317 True if a combined email should be produced when a single
2318 new commit is pushed to a branch, False otherwise.
2320 from_refchange, from_commit (strings)
2322 Addresses to use for the From: field for refchange emails
2323 and commit emails respectively. Set from
2324 multimailhook.fromRefchange and multimailhook.fromCommit
2325 by ConfigEnvironmentMixin.
2329 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2331 def __init__(self, osenv=None):
2332 self.osenv = osenv or os.environ
2333 self.announce_show_shortlog = False
2334 self.commit_email_format = "text"
2335 self.html_in_intro = False
2336 self.html_in_footer = False
2337 self.commitBrowseURL = None
2338 self.maxcommitemails = 500
2339 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2340 self.graphopts = ['--oneline', '--decorate']
2341 self.logopts = []
2342 self.refchange_showgraph = False
2343 self.refchange_showlog = False
2344 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2345 self.date_substitute = 'AuthorDate: '
2346 self.quiet = False
2347 self.stdout = False
2348 self.combine_when_single_commit = True
2350 self.COMPUTED_KEYS = [
2351 'administrator',
2352 'charset',
2353 'emailprefix',
2354 'pusher',
2355 'pusher_email',
2356 'repo_path',
2357 'repo_shortname',
2358 'sender',
2361 self._values = None
2363 def get_repo_shortname(self):
2364 """Use the last part of the repo path, with ".git" stripped off if present."""
2366 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2367 m = self.REPO_NAME_RE.match(basename)
2368 if m:
2369 return m.group('name')
2370 else:
2371 return basename
2373 def get_pusher(self):
2374 raise NotImplementedError()
2376 def get_pusher_email(self):
2377 return None
2379 def get_fromaddr(self, change=None):
2380 config = Config('user')
2381 fromname = config.get('name', default='')
2382 fromemail = config.get('email', default='')
2383 if fromemail:
2384 return formataddr([fromname, fromemail])
2385 return self.get_sender()
2387 def get_administrator(self):
2388 return 'the administrator of this repository'
2390 def get_emailprefix(self):
2391 return ''
2393 def get_repo_path(self):
2394 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2395 path = get_git_dir()
2396 else:
2397 path = read_git_output(['rev-parse', '--show-toplevel'])
2398 return os.path.abspath(path)
2400 def get_charset(self):
2401 return CHARSET
2403 def get_values(self):
2404 """Return a dictionary {keyword: expansion} for this Environment.
2406 This method is called by Change._compute_values(). The keys
2407 in the returned dictionary are available to be used in any of
2408 the templates. The dictionary is created by calling
2409 self.get_NAME() for each of the attributes named in
2410 COMPUTED_KEYS and recording those that do not return None.
2411 The return value is always a new dictionary."""
2413 if self._values is None:
2414 values = {'': ''} # %()s expands to the empty string.
2416 for key in self.COMPUTED_KEYS:
2417 value = getattr(self, 'get_%s' % (key,))()
2418 if value is not None:
2419 values[key] = value
2421 self._values = values
2423 return self._values.copy()
2425 def get_refchange_recipients(self, refchange):
2426 """Return the recipients for notifications about refchange.
2428 Return the list of email addresses to which notifications
2429 about the specified ReferenceChange should be sent."""
2431 raise NotImplementedError()
2433 def get_announce_recipients(self, annotated_tag_change):
2434 """Return the recipients for notifications about annotated_tag_change.
2436 Return the list of email addresses to which notifications
2437 about the specified AnnotatedTagChange should be sent."""
2439 raise NotImplementedError()
2441 def get_reply_to_refchange(self, refchange):
2442 return self.get_pusher_email()
2444 def get_revision_recipients(self, revision):
2445 """Return the recipients for messages about revision.
2447 Return the list of email addresses to which notifications
2448 about the specified Revision should be sent. This method
2449 could be overridden, for example, to take into account the
2450 contents of the revision when deciding whom to notify about
2451 it. For example, there could be a scheme for users to express
2452 interest in particular files or subdirectories, and only
2453 receive notification emails for revisions that affecting those
2454 files."""
2456 raise NotImplementedError()
2458 def get_reply_to_commit(self, revision):
2459 return revision.author
2461 def get_default_ref_ignore_regex(self):
2462 # The commit messages of git notes are essentially meaningless
2463 # and "filenames" in git notes commits are an implementational
2464 # detail that might surprise users at first. As such, we
2465 # would need a completely different method for handling emails
2466 # of git notes in order for them to be of benefit for users,
2467 # which we simply do not have right now.
2468 return "^refs/notes/"
2470 def filter_body(self, lines):
2471 """Filter the lines intended for an email body.
2473 lines is an iterable over the lines that would go into the
2474 email body. Filter it (e.g., limit the number of lines, the
2475 line length, character set, etc.), returning another iterable.
2476 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2477 for classes implementing this functionality."""
2479 return lines
2481 def log_msg(self, msg):
2482 """Write the string msg on a log file or on stderr.
2484 Sends the text to stderr by default, override to change the behavior."""
2485 write_str(sys.stderr, msg)
2487 def log_warning(self, msg):
2488 """Write the string msg on a log file or on stderr.
2490 Sends the text to stderr by default, override to change the behavior."""
2491 write_str(sys.stderr, msg)
2493 def log_error(self, msg):
2494 """Write the string msg on a log file or on stderr.
2496 Sends the text to stderr by default, override to change the behavior."""
2497 write_str(sys.stderr, msg)
2500 class ConfigEnvironmentMixin(Environment):
2501 """A mixin that sets self.config to its constructor's config argument.
2503 This class's constructor consumes the "config" argument.
2505 Mixins that need to inspect the config should inherit from this
2506 class (1) to make sure that "config" is still in the constructor
2507 arguments with its own constructor runs and/or (2) to be sure that
2508 self.config is set after construction."""
2510 def __init__(self, config, **kw):
2511 super(ConfigEnvironmentMixin, self).__init__(**kw)
2512 self.config = config
2515 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2516 """An Environment that reads most of its information from "git config"."""
2518 @staticmethod
2519 def forbid_field_values(name, value, forbidden):
2520 for forbidden_val in forbidden:
2521 if value is not None and value.lower() == forbidden:
2522 raise ConfigurationException(
2523 '"%s" is not an allowed setting for %s' % (value, name)
2526 def __init__(self, config, **kw):
2527 super(ConfigOptionsEnvironmentMixin, self).__init__(
2528 config=config, **kw
2531 for var, cfg in (
2532 ('announce_show_shortlog', 'announceshortlog'),
2533 ('refchange_showgraph', 'refchangeShowGraph'),
2534 ('refchange_showlog', 'refchangeshowlog'),
2535 ('quiet', 'quiet'),
2536 ('stdout', 'stdout'),
2538 val = config.get_bool(cfg)
2539 if val is not None:
2540 setattr(self, var, val)
2542 commit_email_format = config.get('commitEmailFormat')
2543 if commit_email_format is not None:
2544 if commit_email_format != "html" and commit_email_format != "text":
2545 self.log_warning(
2546 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2547 commit_email_format +
2548 '*** Expected either "text" or "html". Ignoring.\n'
2550 else:
2551 self.commit_email_format = commit_email_format
2553 html_in_intro = config.get_bool('htmlInIntro')
2554 if html_in_intro is not None:
2555 self.html_in_intro = html_in_intro
2557 html_in_footer = config.get_bool('htmlInFooter')
2558 if html_in_footer is not None:
2559 self.html_in_footer = html_in_footer
2561 self.commitBrowseURL = config.get('commitBrowseURL')
2563 maxcommitemails = config.get('maxcommitemails')
2564 if maxcommitemails is not None:
2565 try:
2566 self.maxcommitemails = int(maxcommitemails)
2567 except ValueError:
2568 self.log_warning(
2569 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2570 % maxcommitemails +
2571 '*** Expected a number. Ignoring.\n'
2574 diffopts = config.get('diffopts')
2575 if diffopts is not None:
2576 self.diffopts = shlex.split(diffopts)
2578 graphopts = config.get('graphOpts')
2579 if graphopts is not None:
2580 self.graphopts = shlex.split(graphopts)
2582 logopts = config.get('logopts')
2583 if logopts is not None:
2584 self.logopts = shlex.split(logopts)
2586 commitlogopts = config.get('commitlogopts')
2587 if commitlogopts is not None:
2588 self.commitlogopts = shlex.split(commitlogopts)
2590 date_substitute = config.get('dateSubstitute')
2591 if date_substitute == 'none':
2592 self.date_substitute = None
2593 elif date_substitute is not None:
2594 self.date_substitute = date_substitute
2596 reply_to = config.get('replyTo')
2597 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2598 self.forbid_field_values('replyToRefchange',
2599 self.__reply_to_refchange,
2600 ['author'])
2601 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2603 self.from_refchange = config.get('fromRefchange')
2604 self.forbid_field_values('fromRefchange',
2605 self.from_refchange,
2606 ['author', 'none'])
2607 self.from_commit = config.get('fromCommit')
2608 self.forbid_field_values('fromCommit',
2609 self.from_commit,
2610 ['none'])
2612 combine = config.get_bool('combineWhenSingleCommit')
2613 if combine is not None:
2614 self.combine_when_single_commit = combine
2616 def get_administrator(self):
2617 return (
2618 self.config.get('administrator') or
2619 self.get_sender() or
2620 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2623 def get_repo_shortname(self):
2624 return (
2625 self.config.get('reponame') or
2626 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2629 def get_emailprefix(self):
2630 emailprefix = self.config.get('emailprefix')
2631 if emailprefix is not None:
2632 emailprefix = emailprefix.strip()
2633 if emailprefix:
2634 return emailprefix + ' '
2635 else:
2636 return ''
2637 else:
2638 return '[%s] ' % (self.get_repo_shortname(),)
2640 def get_sender(self):
2641 return self.config.get('envelopesender')
2643 def process_addr(self, addr, change):
2644 if addr.lower() == 'author':
2645 if hasattr(change, 'author'):
2646 return change.author
2647 else:
2648 return None
2649 elif addr.lower() == 'pusher':
2650 return self.get_pusher_email()
2651 elif addr.lower() == 'none':
2652 return None
2653 else:
2654 return addr
2656 def get_fromaddr(self, change=None):
2657 fromaddr = self.config.get('from')
2658 if change:
2659 alt_fromaddr = change.get_alt_fromaddr()
2660 if alt_fromaddr:
2661 fromaddr = alt_fromaddr
2662 if fromaddr:
2663 fromaddr = self.process_addr(fromaddr, change)
2664 if fromaddr:
2665 return fromaddr
2666 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2668 def get_reply_to_refchange(self, refchange):
2669 if self.__reply_to_refchange is None:
2670 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2671 else:
2672 return self.process_addr(self.__reply_to_refchange, refchange)
2674 def get_reply_to_commit(self, revision):
2675 if self.__reply_to_commit is None:
2676 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2677 else:
2678 return self.process_addr(self.__reply_to_commit, revision)
2680 def get_scancommitforcc(self):
2681 return self.config.get('scancommitforcc')
2684 class FilterLinesEnvironmentMixin(Environment):
2685 """Handle encoding and maximum line length of body lines.
2687 emailmaxlinelength (int or None)
2689 The maximum length of any single line in the email body.
2690 Longer lines are truncated at that length with ' [...]'
2691 appended.
2693 strict_utf8 (bool)
2695 If this field is set to True, then the email body text is
2696 expected to be UTF-8. Any invalid characters are
2697 converted to U+FFFD, the Unicode replacement character
2698 (encoded as UTF-8, of course).
2702 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
2703 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2704 self.__strict_utf8 = strict_utf8
2705 self.__emailmaxlinelength = emailmaxlinelength
2707 def filter_body(self, lines):
2708 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2709 if self.__strict_utf8:
2710 if not PYTHON3:
2711 lines = (line.decode(ENCODING, 'replace') for line in lines)
2712 # Limit the line length in Unicode-space to avoid
2713 # splitting characters:
2714 if self.__emailmaxlinelength:
2715 lines = limit_linelength(lines, self.__emailmaxlinelength)
2716 if not PYTHON3:
2717 lines = (line.encode(ENCODING, 'replace') for line in lines)
2718 elif self.__emailmaxlinelength:
2719 lines = limit_linelength(lines, self.__emailmaxlinelength)
2721 return lines
2724 class ConfigFilterLinesEnvironmentMixin(
2725 ConfigEnvironmentMixin,
2726 FilterLinesEnvironmentMixin,
2728 """Handle encoding and maximum line length based on config."""
2730 def __init__(self, config, **kw):
2731 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2732 if strict_utf8 is not None:
2733 kw['strict_utf8'] = strict_utf8
2735 emailmaxlinelength = config.get('emailmaxlinelength')
2736 if emailmaxlinelength is not None:
2737 kw['emailmaxlinelength'] = int(emailmaxlinelength)
2739 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2740 config=config, **kw
2744 class MaxlinesEnvironmentMixin(Environment):
2745 """Limit the email body to a specified number of lines."""
2747 def __init__(self, emailmaxlines, **kw):
2748 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2749 self.__emailmaxlines = emailmaxlines
2751 def filter_body(self, lines):
2752 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2753 if self.__emailmaxlines:
2754 lines = limit_lines(lines, self.__emailmaxlines)
2755 return lines
2758 class ConfigMaxlinesEnvironmentMixin(
2759 ConfigEnvironmentMixin,
2760 MaxlinesEnvironmentMixin,
2762 """Limit the email body to the number of lines specified in config."""
2764 def __init__(self, config, **kw):
2765 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2766 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2767 config=config,
2768 emailmaxlines=emailmaxlines,
2769 **kw
2773 class FQDNEnvironmentMixin(Environment):
2774 """A mixin that sets the host's FQDN to its constructor argument."""
2776 def __init__(self, fqdn, **kw):
2777 super(FQDNEnvironmentMixin, self).__init__(**kw)
2778 self.COMPUTED_KEYS += ['fqdn']
2779 self.__fqdn = fqdn
2781 def get_fqdn(self):
2782 """Return the fully-qualified domain name for this host.
2784 Return None if it is unavailable or unwanted."""
2786 return self.__fqdn
2789 class ConfigFQDNEnvironmentMixin(
2790 ConfigEnvironmentMixin,
2791 FQDNEnvironmentMixin,
2793 """Read the FQDN from the config."""
2795 def __init__(self, config, **kw):
2796 fqdn = config.get('fqdn')
2797 super(ConfigFQDNEnvironmentMixin, self).__init__(
2798 config=config,
2799 fqdn=fqdn,
2800 **kw
2804 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2805 """Get the FQDN by calling socket.getfqdn()."""
2807 def __init__(self, **kw):
2808 super(ComputeFQDNEnvironmentMixin, self).__init__(
2809 fqdn=socket.getfqdn(),
2810 **kw
2814 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2815 """Deduce pusher_email from pusher by appending an emaildomain."""
2817 def __init__(self, **kw):
2818 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2819 self.__emaildomain = self.config.get('emaildomain')
2821 def get_pusher_email(self):
2822 if self.__emaildomain:
2823 # Derive the pusher's full email address in the default way:
2824 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2825 else:
2826 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2829 class StaticRecipientsEnvironmentMixin(Environment):
2830 """Set recipients statically based on constructor parameters."""
2832 def __init__(
2833 self,
2834 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2835 **kw
2837 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2839 # The recipients for various types of notification emails, as
2840 # RFC 2822 email addresses separated by commas (or the empty
2841 # string if no recipients are configured). Although there is
2842 # a mechanism to choose the recipient lists based on on the
2843 # actual *contents* of the change being reported, we only
2844 # choose based on the *type* of the change. Therefore we can
2845 # compute them once and for all:
2846 if not (refchange_recipients or
2847 announce_recipients or
2848 revision_recipients or
2849 scancommitforcc):
2850 raise ConfigurationException('No email recipients configured!')
2851 self.__refchange_recipients = refchange_recipients
2852 self.__announce_recipients = announce_recipients
2853 self.__revision_recipients = revision_recipients
2855 def get_refchange_recipients(self, refchange):
2856 return self.__refchange_recipients
2858 def get_announce_recipients(self, annotated_tag_change):
2859 return self.__announce_recipients
2861 def get_revision_recipients(self, revision):
2862 return self.__revision_recipients
2865 class ConfigRecipientsEnvironmentMixin(
2866 ConfigEnvironmentMixin,
2867 StaticRecipientsEnvironmentMixin
2869 """Determine recipients statically based on config."""
2871 def __init__(self, config, **kw):
2872 super(ConfigRecipientsEnvironmentMixin, self).__init__(
2873 config=config,
2874 refchange_recipients=self._get_recipients(
2875 config, 'refchangelist', 'mailinglist',
2877 announce_recipients=self._get_recipients(
2878 config, 'announcelist', 'refchangelist', 'mailinglist',
2880 revision_recipients=self._get_recipients(
2881 config, 'commitlist', 'mailinglist',
2883 scancommitforcc=config.get('scancommitforcc'),
2884 **kw
2887 def _get_recipients(self, config, *names):
2888 """Return the recipients for a particular type of message.
2890 Return the list of email addresses to which a particular type
2891 of notification email should be sent, by looking at the config
2892 value for "multimailhook.$name" for each of names. Use the
2893 value from the first name that is configured. The return
2894 value is a (possibly empty) string containing RFC 2822 email
2895 addresses separated by commas. If no configuration could be
2896 found, raise a ConfigurationException."""
2898 for name in names:
2899 lines = config.get_all(name)
2900 if lines is not None:
2901 lines = [line.strip() for line in lines]
2902 # Single "none" is a special value equivalen to empty string.
2903 if lines == ['none']:
2904 lines = ['']
2905 return ', '.join(lines)
2906 else:
2907 return ''
2910 class StaticRefFilterEnvironmentMixin(Environment):
2911 """Set branch filter statically based on constructor parameters."""
2913 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
2914 ref_filter_do_send_regex, ref_filter_dont_send_regex,
2915 **kw):
2916 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
2918 if ref_filter_incl_regex and ref_filter_excl_regex:
2919 raise ConfigurationException(
2920 "Cannot specify both a ref inclusion and exclusion regex.")
2921 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
2922 default_exclude = self.get_default_ref_ignore_regex()
2923 if ref_filter_incl_regex:
2924 ref_filter_regex = ref_filter_incl_regex
2925 elif ref_filter_excl_regex:
2926 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
2927 else:
2928 ref_filter_regex = default_exclude
2929 try:
2930 self.__compiled_regex = re.compile(ref_filter_regex)
2931 except Exception:
2932 raise ConfigurationException(
2933 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
2935 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
2936 raise ConfigurationException(
2937 "Cannot specify both a ref doSend and dontSend regex.")
2938 if ref_filter_do_send_regex or ref_filter_dont_send_regex:
2939 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
2940 if ref_filter_incl_regex:
2941 ref_filter_send_regex = ref_filter_incl_regex
2942 elif ref_filter_excl_regex:
2943 ref_filter_send_regex = ref_filter_excl_regex
2944 else:
2945 ref_filter_send_regex = '.*'
2946 self.__is_do_send_filter = True
2947 try:
2948 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
2949 except Exception:
2950 raise ConfigurationException(
2951 'Invalid Ref Filter Regex "%s": %s' %
2952 (ref_filter_send_regex, sys.exc_info()[1]))
2953 else:
2954 self.__send_compiled_regex = self.__compiled_regex
2955 self.__is_do_send_filter = self.__is_inclusion_filter
2957 def get_ref_filter_regex(self, send_filter=False):
2958 if send_filter:
2959 return self.__send_compiled_regex, self.__is_do_send_filter
2960 else:
2961 return self.__compiled_regex, self.__is_inclusion_filter
2964 class ConfigRefFilterEnvironmentMixin(
2965 ConfigEnvironmentMixin,
2966 StaticRefFilterEnvironmentMixin
2968 """Determine branch filtering statically based on config."""
2970 def _get_regex(self, config, key):
2971 """Get a list of whitespace-separated regex. The refFilter* config
2972 variables are multivalued (hence the use of get_all), and we
2973 allow each entry to be a whitespace-separated list (hence the
2974 split on each line). The whole thing is glued into a single regex."""
2975 values = config.get_all(key)
2976 if values is None:
2977 return values
2978 items = []
2979 for line in values:
2980 for i in line.split():
2981 items.append(i)
2982 if items == []:
2983 return None
2984 return '|'.join(items)
2986 def __init__(self, config, **kw):
2987 super(ConfigRefFilterEnvironmentMixin, self).__init__(
2988 config=config,
2989 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
2990 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
2991 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
2992 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
2993 **kw
2997 class ProjectdescEnvironmentMixin(Environment):
2998 """Make a "projectdesc" value available for templates.
3000 By default, it is set to the first line of $GIT_DIR/description
3001 (if that file is present and appears to be set meaningfully)."""
3003 def __init__(self, **kw):
3004 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3005 self.COMPUTED_KEYS += ['projectdesc']
3007 def get_projectdesc(self):
3008 """Return a one-line descripition of the project."""
3010 git_dir = get_git_dir()
3011 try:
3012 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3013 if projectdesc and not projectdesc.startswith('Unnamed repository'):
3014 return projectdesc
3015 except IOError:
3016 pass
3018 return 'UNNAMED PROJECT'
3021 class GenericEnvironmentMixin(Environment):
3022 def get_pusher(self):
3023 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3026 class GenericEnvironment(
3027 ProjectdescEnvironmentMixin,
3028 ConfigMaxlinesEnvironmentMixin,
3029 ComputeFQDNEnvironmentMixin,
3030 ConfigFilterLinesEnvironmentMixin,
3031 ConfigRecipientsEnvironmentMixin,
3032 ConfigRefFilterEnvironmentMixin,
3033 PusherDomainEnvironmentMixin,
3034 ConfigOptionsEnvironmentMixin,
3035 GenericEnvironmentMixin,
3036 Environment,
3038 pass
3041 class GitoliteEnvironmentMixin(Environment):
3042 def get_repo_shortname(self):
3043 # The gitolite environment variable $GL_REPO is a pretty good
3044 # repo_shortname (though it's probably not as good as a value
3045 # the user might have explicitly put in his config).
3046 return (
3047 self.osenv.get('GL_REPO', None) or
3048 super(GitoliteEnvironmentMixin, self).get_repo_shortname()
3051 def get_pusher(self):
3052 return self.osenv.get('GL_USER', 'unknown user')
3054 def get_fromaddr(self, change=None):
3055 GL_USER = self.osenv.get('GL_USER')
3056 if GL_USER is not None:
3057 # Find the path to gitolite.conf. Note that gitolite v3
3058 # did away with the GL_ADMINDIR and GL_CONF environment
3059 # variables (they are now hard-coded).
3060 GL_ADMINDIR = self.osenv.get(
3061 'GL_ADMINDIR',
3062 os.path.expanduser(os.path.join('~', '.gitolite')))
3063 GL_CONF = self.osenv.get(
3064 'GL_CONF',
3065 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3066 if os.path.isfile(GL_CONF):
3067 f = open(GL_CONF, 'rU')
3068 try:
3069 in_user_emails_section = False
3070 re_template = r'^\s*#\s*%s\s*$'
3071 re_begin, re_user, re_end = (
3072 re.compile(re_template % x)
3073 for x in (
3074 r'BEGIN\s+USER\s+EMAILS',
3075 re.escape(GL_USER) + r'\s+(.*)',
3076 r'END\s+USER\s+EMAILS',
3078 for l in f:
3079 l = l.rstrip('\n')
3080 if not in_user_emails_section:
3081 if re_begin.match(l):
3082 in_user_emails_section = True
3083 continue
3084 if re_end.match(l):
3085 break
3086 m = re_user.match(l)
3087 if m:
3088 return m.group(1)
3089 finally:
3090 f.close()
3091 return super(GitoliteEnvironmentMixin, self).get_fromaddr(change)
3094 class IncrementalDateTime(object):
3095 """Simple wrapper to give incremental date/times.
3097 Each call will result in a date/time a second later than the
3098 previous call. This can be used to falsify email headers, to
3099 increase the likelihood that email clients sort the emails
3100 correctly."""
3102 def __init__(self):
3103 self.time = time.time()
3104 self.next = self.__next__ # Python 2 backward compatibility
3106 def __next__(self):
3107 formatted = formatdate(self.time, True)
3108 self.time += 1
3109 return formatted
3112 class GitoliteEnvironment(
3113 ProjectdescEnvironmentMixin,
3114 ConfigMaxlinesEnvironmentMixin,
3115 ComputeFQDNEnvironmentMixin,
3116 ConfigFilterLinesEnvironmentMixin,
3117 ConfigRecipientsEnvironmentMixin,
3118 ConfigRefFilterEnvironmentMixin,
3119 PusherDomainEnvironmentMixin,
3120 ConfigOptionsEnvironmentMixin,
3121 GitoliteEnvironmentMixin,
3122 Environment,
3124 pass
3127 class StashEnvironmentMixin(Environment):
3128 def __init__(self, user=None, repo=None, **kw):
3129 super(StashEnvironmentMixin, self).__init__(**kw)
3130 self.__user = user
3131 self.__repo = repo
3133 def get_repo_shortname(self):
3134 return self.__repo
3136 def get_pusher(self):
3137 return re.match('(.*?)\s*<', self.__user).group(1)
3139 def get_pusher_email(self):
3140 return self.__user
3142 def get_fromaddr(self, change=None):
3143 return self.__user
3146 class StashEnvironment(
3147 StashEnvironmentMixin,
3148 ProjectdescEnvironmentMixin,
3149 ConfigMaxlinesEnvironmentMixin,
3150 ComputeFQDNEnvironmentMixin,
3151 ConfigFilterLinesEnvironmentMixin,
3152 ConfigRecipientsEnvironmentMixin,
3153 ConfigRefFilterEnvironmentMixin,
3154 PusherDomainEnvironmentMixin,
3155 ConfigOptionsEnvironmentMixin,
3156 Environment,
3158 pass
3161 class GerritEnvironmentMixin(Environment):
3162 def __init__(self, project=None, submitter=None, update_method=None, **kw):
3163 super(GerritEnvironmentMixin, self).__init__(**kw)
3164 self.__project = project
3165 self.__submitter = submitter
3166 self.__update_method = update_method
3167 "Make an 'update_method' value available for templates."
3168 self.COMPUTED_KEYS += ['update_method']
3170 def get_repo_shortname(self):
3171 return self.__project
3173 def get_pusher(self):
3174 if self.__submitter:
3175 if self.__submitter.find('<') != -1:
3176 # Submitter has a configured email, we transformed
3177 # __submitter into an RFC 2822 string already.
3178 return re.match('(.*?)\s*<', self.__submitter).group(1)
3179 else:
3180 # Submitter has no configured email, it's just his name.
3181 return self.__submitter
3182 else:
3183 # If we arrive here, this means someone pushed "Submit" from
3184 # the gerrit web UI for the CR (or used one of the programmatic
3185 # APIs to do the same, such as gerrit review) and the
3186 # merge/push was done by the Gerrit user. It was technically
3187 # triggered by someone else, but sadly we have no way of
3188 # determining who that someone else is at this point.
3189 return 'Gerrit' # 'unknown user'?
3191 def get_pusher_email(self):
3192 if self.__submitter:
3193 return self.__submitter
3194 else:
3195 return super(GerritEnvironmentMixin, self).get_pusher_email()
3197 def get_fromaddr(self, change=None):
3198 if self.__submitter and self.__submitter.find('<') != -1:
3199 return self.__submitter
3200 else:
3201 return super(GerritEnvironmentMixin, self).get_fromaddr(change)
3203 def get_default_ref_ignore_regex(self):
3204 default = super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()
3205 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3207 def get_revision_recipients(self, revision):
3208 # Merge commits created by Gerrit when users hit "Submit this patchset"
3209 # in the Web UI (or do equivalently with REST APIs or the gerrit review
3210 # command) are not something users want to see an individual email for.
3211 # Filter them out.
3212 committer = read_git_output(['log', '--no-walk', '--format=%cN',
3213 revision.rev.sha1])
3214 if committer == 'Gerrit Code Review':
3215 return []
3216 else:
3217 return super(GerritEnvironmentMixin, self).get_revision_recipients(revision)
3219 def get_update_method(self):
3220 return self.__update_method
3223 class GerritEnvironment(
3224 GerritEnvironmentMixin,
3225 ProjectdescEnvironmentMixin,
3226 ConfigMaxlinesEnvironmentMixin,
3227 ComputeFQDNEnvironmentMixin,
3228 ConfigFilterLinesEnvironmentMixin,
3229 ConfigRecipientsEnvironmentMixin,
3230 ConfigRefFilterEnvironmentMixin,
3231 PusherDomainEnvironmentMixin,
3232 ConfigOptionsEnvironmentMixin,
3233 Environment,
3235 pass
3238 class Push(object):
3239 """Represent an entire push (i.e., a group of ReferenceChanges).
3241 It is easy to figure out what commits were added to a *branch* by
3242 a Reference change:
3244 git rev-list change.old..change.new
3246 or removed from a *branch*:
3248 git rev-list change.new..change.old
3250 But it is not quite so trivial to determine which entirely new
3251 commits were added to the *repository* by a push and which old
3252 commits were discarded by a push. A big part of the job of this
3253 class is to figure out these things, and to make sure that new
3254 commits are only detailed once even if they were added to multiple
3255 references.
3257 The first step is to determine the "other" references--those
3258 unaffected by the current push. They are computed by listing all
3259 references then removing any affected by this push. The results
3260 are stored in Push._other_ref_sha1s.
3262 The commits contained in the repository before this push were
3264 git rev-list other1 other2 other3 ... change1.old change2.old ...
3266 Where "changeN.old" is the old value of one of the references
3267 affected by this push.
3269 The commits contained in the repository after this push are
3271 git rev-list other1 other2 other3 ... change1.new change2.new ...
3273 The commits added by this push are the difference between these
3274 two sets, which can be written
3276 git rev-list \
3277 ^other1 ^other2 ... \
3278 ^change1.old ^change2.old ... \
3279 change1.new change2.new ...
3281 The commits removed by this push can be computed by
3283 git rev-list \
3284 ^other1 ^other2 ... \
3285 ^change1.new ^change2.new ... \
3286 change1.old change2.old ...
3288 The last point is that it is possible that other pushes are
3289 occurring simultaneously to this one, so reference values can
3290 change at any time. It is impossible to eliminate all race
3291 conditions, but we reduce the window of time during which problems
3292 can occur by translating reference names to SHA1s as soon as
3293 possible and working with SHA1s thereafter (because SHA1s are
3294 immutable)."""
3296 # A map {(changeclass, changetype): integer} specifying the order
3297 # that reference changes will be processed if multiple reference
3298 # changes are included in a single push. The order is significant
3299 # mostly because new commit notifications are threaded together
3300 # with the first reference change that includes the commit. The
3301 # following order thus causes commits to be grouped with branch
3302 # changes (as opposed to tag changes) if possible.
3303 SORT_ORDER = dict(
3304 (value, i) for (i, value) in enumerate([
3305 (BranchChange, 'update'),
3306 (BranchChange, 'create'),
3307 (AnnotatedTagChange, 'update'),
3308 (AnnotatedTagChange, 'create'),
3309 (NonAnnotatedTagChange, 'update'),
3310 (NonAnnotatedTagChange, 'create'),
3311 (BranchChange, 'delete'),
3312 (AnnotatedTagChange, 'delete'),
3313 (NonAnnotatedTagChange, 'delete'),
3314 (OtherReferenceChange, 'update'),
3315 (OtherReferenceChange, 'create'),
3316 (OtherReferenceChange, 'delete'),
3320 def __init__(self, environment, changes, ignore_other_refs=False):
3321 self.changes = sorted(changes, key=self._sort_key)
3322 self.__other_ref_sha1s = None
3323 self.__cached_commits_spec = {}
3324 self.environment = environment
3326 if ignore_other_refs:
3327 self.__other_ref_sha1s = set()
3329 @classmethod
3330 def _sort_key(klass, change):
3331 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3333 @property
3334 def _other_ref_sha1s(self):
3335 """The GitObjects referred to by references unaffected by this push.
3337 if self.__other_ref_sha1s is None:
3338 # The refnames being changed by this push:
3339 updated_refs = set(
3340 change.refname
3341 for change in self.changes
3344 # The SHA-1s of commits referred to by all references in this
3345 # repository *except* updated_refs:
3346 sha1s = set()
3347 fmt = (
3348 '%(objectname) %(objecttype) %(refname)\n'
3349 '%(*objectname) %(*objecttype) %(refname)'
3351 ref_filter_regex, is_inclusion_filter = \
3352 self.environment.get_ref_filter_regex()
3353 for line in read_git_lines(
3354 ['for-each-ref', '--format=%s' % (fmt,)]):
3355 (sha1, type, name) = line.split(' ', 2)
3356 if (sha1 and type == 'commit' and
3357 name not in updated_refs and
3358 include_ref(name, ref_filter_regex, is_inclusion_filter)):
3359 sha1s.add(sha1)
3361 self.__other_ref_sha1s = sha1s
3363 return self.__other_ref_sha1s
3365 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3366 """Get new or old SHA-1 from one or each of the changed refs.
3368 Return a list of SHA-1 commit identifier strings suitable as
3369 arguments to 'git rev-list' (or 'git log' or ...). The
3370 returned identifiers are either the old or new values from one
3371 or all of the changed references, depending on the values of
3372 new_or_old and reference_change.
3374 new_or_old is either the string 'new' or the string 'old'. If
3375 'new', the returned SHA-1 identifiers are the new values from
3376 each changed reference. If 'old', the SHA-1 identifiers are
3377 the old values from each changed reference.
3379 If reference_change is specified and not None, only the new or
3380 old reference from the specified reference is included in the
3381 return value.
3383 This function returns None if there are no matching revisions
3384 (e.g., because a branch was deleted and new_or_old is 'new').
3387 if not reference_change:
3388 incl_spec = sorted(
3389 getattr(change, new_or_old).sha1
3390 for change in self.changes
3391 if getattr(change, new_or_old)
3393 if not incl_spec:
3394 incl_spec = None
3395 elif not getattr(reference_change, new_or_old).commit_sha1:
3396 incl_spec = None
3397 else:
3398 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3399 return incl_spec
3401 def _get_commits_spec_excl(self, new_or_old):
3402 """Get exclusion revisions for determining new or discarded commits.
3404 Return a list of strings suitable as arguments to 'git
3405 rev-list' (or 'git log' or ...) that will exclude all
3406 commits that, depending on the value of new_or_old, were
3407 either previously in the repository (useful for determining
3408 which commits are new to the repository) or currently in the
3409 repository (useful for determining which commits were
3410 discarded from the repository).
3412 new_or_old is either the string 'new' or the string 'old'. If
3413 'new', the commits to be excluded are those that were in the
3414 repository before the push. If 'old', the commits to be
3415 excluded are those that are currently in the repository. """
3417 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3418 excl_revs = self._other_ref_sha1s.union(
3419 getattr(change, old_or_new).sha1
3420 for change in self.changes
3421 if getattr(change, old_or_new).type in ['commit', 'tag']
3423 return ['^' + sha1 for sha1 in sorted(excl_revs)]
3425 def get_commits_spec(self, new_or_old, reference_change=None):
3426 """Get rev-list arguments for added or discarded commits.
3428 Return a list of strings suitable as arguments to 'git
3429 rev-list' (or 'git log' or ...) that select those commits
3430 that, depending on the value of new_or_old, are either new to
3431 the repository or were discarded from the repository.
3433 new_or_old is either the string 'new' or the string 'old'. If
3434 'new', the returned list is used to select commits that are
3435 new to the repository. If 'old', the returned value is used
3436 to select the commits that have been discarded from the
3437 repository.
3439 If reference_change is specified and not None, the new or
3440 discarded commits are limited to those that are reachable from
3441 the new or old value of the specified reference.
3443 This function returns None if there are no added (or discarded)
3444 revisions.
3446 key = (new_or_old, reference_change)
3447 if key not in self.__cached_commits_spec:
3448 ret = self._get_commits_spec_incl(new_or_old, reference_change)
3449 if ret is not None:
3450 ret.extend(self._get_commits_spec_excl(new_or_old))
3451 self.__cached_commits_spec[key] = ret
3452 return self.__cached_commits_spec[key]
3454 def get_new_commits(self, reference_change=None):
3455 """Return a list of commits added by this push.
3457 Return a list of the object names of commits that were added
3458 by the part of this push represented by reference_change. If
3459 reference_change is None, then return a list of *all* commits
3460 added by this push."""
3462 spec = self.get_commits_spec('new', reference_change)
3463 return git_rev_list(spec)
3465 def get_discarded_commits(self, reference_change):
3466 """Return a list of commits discarded by this push.
3468 Return a list of the object names of commits that were
3469 entirely discarded from the repository by the part of this
3470 push represented by reference_change."""
3472 spec = self.get_commits_spec('old', reference_change)
3473 return git_rev_list(spec)
3475 def send_emails(self, mailer, body_filter=None):
3476 """Use send all of the notification emails needed for this push.
3478 Use send all of the notification emails (including reference
3479 change emails and commit emails) needed for this push. Send
3480 the emails using mailer. If body_filter is not None, then use
3481 it to filter the lines that are intended for the email
3482 body."""
3484 # The sha1s of commits that were introduced by this push.
3485 # They will be removed from this set as they are processed, to
3486 # guarantee that one (and only one) email is generated for
3487 # each new commit.
3488 unhandled_sha1s = set(self.get_new_commits())
3489 send_date = IncrementalDateTime()
3490 for change in self.changes:
3491 sha1s = []
3492 for sha1 in reversed(list(self.get_new_commits(change))):
3493 if sha1 in unhandled_sha1s:
3494 sha1s.append(sha1)
3495 unhandled_sha1s.remove(sha1)
3497 # Check if we've got anyone to send to
3498 if not change.recipients:
3499 change.environment.log_warning(
3500 '*** no recipients configured so no email will be sent\n'
3501 '*** for %r update %s->%s\n'
3502 % (change.refname, change.old.sha1, change.new.sha1,)
3504 else:
3505 if not change.environment.quiet:
3506 change.environment.log_msg(
3507 'Sending notification emails to: %s\n' % (change.recipients,))
3508 extra_values = {'send_date': next(send_date)}
3510 rev = change.send_single_combined_email(sha1s)
3511 if rev:
3512 mailer.send(
3513 change.generate_combined_email(self, rev, body_filter, extra_values),
3514 rev.recipients,
3516 # This change is now fully handled; no need to handle
3517 # individual revisions any further.
3518 continue
3519 else:
3520 mailer.send(
3521 change.generate_email(self, body_filter, extra_values),
3522 change.recipients,
3525 max_emails = change.environment.maxcommitemails
3526 if max_emails and len(sha1s) > max_emails:
3527 change.environment.log_warning(
3528 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3529 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3530 '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3532 return
3534 for (num, sha1) in enumerate(sha1s):
3535 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3536 if not rev.recipients and rev.cc_recipients:
3537 change.environment.log_msg('*** Replacing Cc: with To:\n')
3538 rev.recipients = rev.cc_recipients
3539 rev.cc_recipients = None
3540 if rev.recipients:
3541 extra_values = {'send_date': next(send_date)}
3542 mailer.send(
3543 rev.generate_email(self, body_filter, extra_values),
3544 rev.recipients,
3547 # Consistency check:
3548 if unhandled_sha1s:
3549 change.environment.log_error(
3550 'ERROR: No emails were sent for the following new commits:\n'
3551 ' %s\n'
3552 % ('\n '.join(sorted(unhandled_sha1s)),)
3556 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3557 does_match = bool(ref_filter_regex.search(refname))
3558 if is_inclusion_filter:
3559 return does_match
3560 else: # exclusion filter -- we include the ref if the regex doesn't match
3561 return not does_match
3564 def run_as_post_receive_hook(environment, mailer):
3565 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3566 changes = []
3567 for line in sys.stdin:
3568 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3569 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3570 continue
3571 changes.append(
3572 ReferenceChange.create(environment, oldrev, newrev, refname)
3574 if changes:
3575 push = Push(environment, changes)
3576 push.send_emails(mailer, body_filter=environment.filter_body)
3577 if hasattr(mailer, '__del__'):
3578 mailer.__del__()
3581 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3582 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3583 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3584 return
3585 changes = [
3586 ReferenceChange.create(
3587 environment,
3588 read_git_output(['rev-parse', '--verify', oldrev]),
3589 read_git_output(['rev-parse', '--verify', newrev]),
3590 refname,
3593 push = Push(environment, changes, force_send)
3594 push.send_emails(mailer, body_filter=environment.filter_body)
3595 if hasattr(mailer, '__del__'):
3596 mailer.__del__()
3599 def choose_mailer(config, environment):
3600 mailer = config.get('mailer', default='sendmail')
3602 if mailer == 'smtp':
3603 smtpserver = config.get('smtpserver', default='localhost')
3604 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3605 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3606 smtpencryption = config.get('smtpencryption', default='none')
3607 smtpuser = config.get('smtpuser', default='')
3608 smtppass = config.get('smtppass', default='')
3609 smtpcacerts = config.get('smtpcacerts', default='')
3610 mailer = SMTPMailer(
3611 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3612 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3613 smtpserverdebuglevel=smtpserverdebuglevel,
3614 smtpencryption=smtpencryption,
3615 smtpuser=smtpuser,
3616 smtppass=smtppass,
3617 smtpcacerts=smtpcacerts
3619 elif mailer == 'sendmail':
3620 command = config.get('sendmailcommand')
3621 if command:
3622 command = shlex.split(command)
3623 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
3624 else:
3625 environment.log_error(
3626 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3627 'please use one of "smtp" or "sendmail".\n'
3629 sys.exit(1)
3630 return mailer
3633 KNOWN_ENVIRONMENTS = {
3634 'generic': GenericEnvironmentMixin,
3635 'gitolite': GitoliteEnvironmentMixin,
3636 'stash': StashEnvironmentMixin,
3637 'gerrit': GerritEnvironmentMixin,
3641 def choose_environment(config, osenv=None, env=None, recipients=None,
3642 hook_info=None):
3643 if not osenv:
3644 osenv = os.environ
3646 environment_mixins = [
3647 ConfigRefFilterEnvironmentMixin,
3648 ProjectdescEnvironmentMixin,
3649 ConfigMaxlinesEnvironmentMixin,
3650 ComputeFQDNEnvironmentMixin,
3651 ConfigFilterLinesEnvironmentMixin,
3652 PusherDomainEnvironmentMixin,
3653 ConfigOptionsEnvironmentMixin,
3655 environment_kw = {
3656 'osenv': osenv,
3657 'config': config,
3660 if not env:
3661 env = config.get('environment')
3663 if not env:
3664 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3665 env = 'gitolite'
3666 else:
3667 env = 'generic'
3669 environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])
3671 if env == 'stash':
3672 environment_kw['user'] = hook_info['stash_user']
3673 environment_kw['repo'] = hook_info['stash_repo']
3674 elif env == 'gerrit':
3675 environment_kw['project'] = hook_info['project']
3676 environment_kw['submitter'] = hook_info['submitter']
3677 environment_kw['update_method'] = hook_info['update_method']
3679 if recipients:
3680 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
3681 environment_kw['refchange_recipients'] = recipients
3682 environment_kw['announce_recipients'] = recipients
3683 environment_kw['revision_recipients'] = recipients
3684 environment_kw['scancommitforcc'] = config.get('scancommitforcc')
3685 else:
3686 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
3688 environment_klass = type(
3689 'EffectiveEnvironment',
3690 tuple(environment_mixins) + (Environment,),
3693 return environment_klass(**environment_kw)
3696 def get_version():
3697 oldcwd = os.getcwd()
3698 try:
3699 try: