 99177b34db
			
		
	
	99177b34db
	
	
	
		
			
			Changes are described in CHANGES. Contributions-by: Matthieu Moy <git@matthieu-moy.fr> Contributions-by: William Stewart <william.stewart@booking.com> Contributions-by: Ville Skyttä <ville.skytta@iki.fi> Contributions-by: Dirk Olmes <dirk.olmes@codedo.de> Contributions-by: Björn Kautler <Bjoern@Kautler.net> Contributions-by: Konstantin Ryabitsev <konstantin@linuxfoundation.org> Contributions-by: Gareth Pye <garethp@gpsatsys.com.au> Contributions-by: David Lazar <lazard@csail.mit.edu> Signed-off-by: Matthieu Moy <git@matthieu-moy.fr> Signed-off-by: Junio C Hamano <gitster@pobox.com>
		
			
				
	
	
		
			4347 lines
		
	
	
		
			157 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			4347 lines
		
	
	
		
			157 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
| #! /usr/bin/env python
 | |
| 
 | |
| __version__ = '1.5.0'
 | |
| 
 | |
| # Copyright (c) 2015-2016 Matthieu Moy and others
 | |
| # Copyright (c) 2012-2014 Michael Haggerty and others
 | |
| # Derived from contrib/hooks/post-receive-email, which is
 | |
| # Copyright (c) 2007 Andy Parkins
 | |
| # and also includes contributions by other authors.
 | |
| #
 | |
| # This file is part of git-multimail.
 | |
| #
 | |
| # git-multimail is free software: you can redistribute it and/or
 | |
| # modify it under the terms of the GNU General Public License version
 | |
| # 2 as published by the Free Software Foundation.
 | |
| #
 | |
| # This program is distributed in the hope that it will be useful, but
 | |
| # WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 | |
| # General Public License for more details.
 | |
| #
 | |
| # You should have received a copy of the GNU General Public License
 | |
| # along with this program.  If not, see
 | |
| # <http://www.gnu.org/licenses/>.
 | |
| 
 | |
| """Generate notification emails for pushes to a git repository.
 | |
| 
 | |
| This hook sends emails describing changes introduced by pushes to a
 | |
| git repository.  For each reference that was changed, it emits one
 | |
| ReferenceChange email summarizing how the reference was changed,
 | |
| followed by one Revision email for each new commit that was introduced
 | |
| by the reference change.
 | |
| 
 | |
| Each commit is announced in exactly one Revision email.  If the same
 | |
| commit is merged into another branch in the same or a later push, then
 | |
| the ReferenceChange email will list the commit's SHA1 and its one-line
 | |
| summary, but no new Revision email will be generated.
 | |
| 
 | |
| This script is designed to be used as a "post-receive" hook in a git
 | |
| repository (see githooks(5)).  It can also be used as an "update"
 | |
| script, but this usage is not completely reliable and is deprecated.
 | |
| 
 | |
| To help with debugging, this script accepts a --stdout option, which
 | |
| causes the emails to be written to standard output rather than sent
 | |
| using sendmail.
 | |
| 
 | |
| See the accompanying README file for the complete documentation.
 | |
| 
 | |
| """
 | |
| 
 | |
| import sys
 | |
| import os
 | |
| import re
 | |
| import bisect
 | |
| import socket
 | |
| import subprocess
 | |
| import shlex
 | |
| import optparse
 | |
| import logging
 | |
| import smtplib
 | |
| try:
 | |
|     import ssl
 | |
| except ImportError:
 | |
|     # Python < 2.6 do not have ssl, but that's OK if we don't use it.
 | |
|     pass
 | |
| import time
 | |
| 
 | |
| import uuid
 | |
| import base64
 | |
| 
 | |
| PYTHON3 = sys.version_info >= (3, 0)
 | |
| 
 | |
| if sys.version_info <= (2, 5):
 | |
|     def all(iterable):
 | |
|         for element in iterable:
 | |
|             if not element:
 | |
|                 return False
 | |
|         return True
 | |
| 
 | |
| 
 | |
| def is_ascii(s):
 | |
|     return all(ord(c) < 128 and ord(c) > 0 for c in s)
 | |
| 
 | |
| 
 | |
| if PYTHON3:
 | |
|     def is_string(s):
 | |
|         return isinstance(s, str)
 | |
| 
 | |
|     def str_to_bytes(s):
 | |
|         return s.encode(ENCODING)
 | |
| 
 | |
|     def bytes_to_str(s, errors='strict'):
 | |
|         return s.decode(ENCODING, errors)
 | |
| 
 | |
|     unicode = str
 | |
| 
 | |
|     def write_str(f, msg):
 | |
|         # Try outputing with the default encoding. If it fails,
 | |
|         # try UTF-8.
 | |
|         try:
 | |
|             f.buffer.write(msg.encode(sys.getdefaultencoding()))
 | |
|         except UnicodeEncodeError:
 | |
|             f.buffer.write(msg.encode(ENCODING))
 | |
| 
 | |
|     def read_line(f):
 | |
|         # Try reading with the default encoding. If it fails,
 | |
|         # try UTF-8.
 | |
|         out = f.buffer.readline()
 | |
|         try:
 | |
|             return out.decode(sys.getdefaultencoding())
 | |
|         except UnicodeEncodeError:
 | |
|             return out.decode(ENCODING)
 | |
| 
 | |
|     import html
 | |
| 
 | |
|     def html_escape(s):
 | |
|         return html.escape(s)
 | |
| 
 | |
| else:
 | |
|     def is_string(s):
 | |
|         try:
 | |
|             return isinstance(s, basestring)
 | |
|         except NameError:  # Silence Pyflakes warning
 | |
|             raise
 | |
| 
 | |
|     def str_to_bytes(s):
 | |
|         return s
 | |
| 
 | |
|     def bytes_to_str(s, errors='strict'):
 | |
|         return s
 | |
| 
 | |
|     def write_str(f, msg):
 | |
|         f.write(msg)
 | |
| 
 | |
|     def read_line(f):
 | |
|         return f.readline()
 | |
| 
 | |
|     def next(it):
 | |
|         return it.next()
 | |
| 
 | |
|     import cgi
 | |
| 
 | |
|     def html_escape(s):
 | |
|         return cgi.escape(s, True)
 | |
| 
 | |
| try:
 | |
|     from email.charset import Charset
 | |
|     from email.utils import make_msgid
 | |
|     from email.utils import getaddresses
 | |
|     from email.utils import formataddr
 | |
|     from email.utils import formatdate
 | |
|     from email.header import Header
 | |
| except ImportError:
 | |
|     # Prior to Python 2.5, the email module used different names:
 | |
|     from email.Charset import Charset
 | |
|     from email.Utils import make_msgid
 | |
|     from email.Utils import getaddresses
 | |
|     from email.Utils import formataddr
 | |
|     from email.Utils import formatdate
 | |
|     from email.Header import Header
 | |
| 
 | |
| 
 | |
| DEBUG = False
 | |
| 
 | |
| ZEROS = '0' * 40
 | |
| LOGBEGIN = '- Log -----------------------------------------------------------------\n'
 | |
| LOGEND = '-----------------------------------------------------------------------\n'
 | |
| 
 | |
| ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
 | |
| 
 | |
| # It is assumed in many places that the encoding is uniformly UTF-8,
 | |
| # so changing these constants is unsupported.  But define them here
 | |
| # anyway, to make it easier to find (at least most of) the places
 | |
| # where the encoding is important.
 | |
| (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
 | |
| 
 | |
| 
 | |
| REF_CREATED_SUBJECT_TEMPLATE = (
 | |
|     '%(emailprefix)s%(refname_type)s %(short_refname)s created'
 | |
|     ' (now %(newrev_short)s)'
 | |
|     )
 | |
| REF_UPDATED_SUBJECT_TEMPLATE = (
 | |
|     '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
 | |
|     ' (%(oldrev_short)s -> %(newrev_short)s)'
 | |
|     )
 | |
| REF_DELETED_SUBJECT_TEMPLATE = (
 | |
|     '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
 | |
|     ' (was %(oldrev_short)s)'
 | |
|     )
 | |
| 
 | |
| COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
 | |
|     '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
 | |
|     )
 | |
| 
 | |
| REFCHANGE_HEADER_TEMPLATE = """\
 | |
| Date: %(send_date)s
 | |
| To: %(recipients)s
 | |
| Subject: %(subject)s
 | |
| MIME-Version: 1.0
 | |
| Content-Type: text/%(contenttype)s; charset=%(charset)s
 | |
| Content-Transfer-Encoding: 8bit
 | |
| Message-ID: %(msgid)s
 | |
| From: %(fromaddr)s
 | |
| Reply-To: %(reply_to)s
 | |
| Thread-Index: %(thread_index)s
 | |
| X-Git-Host: %(fqdn)s
 | |
| X-Git-Repo: %(repo_shortname)s
 | |
| X-Git-Refname: %(refname)s
 | |
| X-Git-Reftype: %(refname_type)s
 | |
| X-Git-Oldrev: %(oldrev)s
 | |
| X-Git-Newrev: %(newrev)s
 | |
| X-Git-NotificationType: ref_changed
 | |
| X-Git-Multimail-Version: %(multimail_version)s
 | |
| Auto-Submitted: auto-generated
 | |
| """
 | |
| 
 | |
| REFCHANGE_INTRO_TEMPLATE = """\
 | |
| This is an automated email from the git hooks/post-receive script.
 | |
| 
 | |
| %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
 | |
| in repository %(repo_shortname)s.
 | |
| 
 | |
| """
 | |
| 
 | |
| 
 | |
| FOOTER_TEMPLATE = """\
 | |
| 
 | |
| -- \n\
 | |
| To stop receiving notification emails like this one, please contact
 | |
| %(administrator)s.
 | |
| """
 | |
| 
 | |
| 
 | |
| REWIND_ONLY_TEMPLATE = """\
 | |
| This update removed existing revisions from the reference, leaving the
 | |
| reference pointing at a previous point in the repository history.
 | |
| 
 | |
|  * -- * -- N   %(refname)s (%(newrev_short)s)
 | |
|             \\
 | |
|              O -- O -- O   (%(oldrev_short)s)
 | |
| 
 | |
| Any revisions marked "omit" are not gone; other references still
 | |
| refer to them.  Any revisions marked "discard" are gone forever.
 | |
| """
 | |
| 
 | |
| 
 | |
| NON_FF_TEMPLATE = """\
 | |
| This update added new revisions after undoing existing revisions.
 | |
| That is to say, some revisions that were in the old version of the
 | |
| %(refname_type)s are not in the new version.  This situation occurs
 | |
| when a user --force pushes a change and generates a repository
 | |
| containing something like this:
 | |
| 
 | |
|  * -- * -- B -- O -- O -- O   (%(oldrev_short)s)
 | |
|             \\
 | |
|              N -- N -- N   %(refname)s (%(newrev_short)s)
 | |
| 
 | |
| You should already have received notification emails for all of the O
 | |
| revisions, and so the following emails describe only the N revisions
 | |
| from the common base, B.
 | |
| 
 | |
| Any revisions marked "omit" are not gone; other references still
 | |
| refer to them.  Any revisions marked "discard" are gone forever.
 | |
| """
 | |
| 
 | |
| 
 | |
| NO_NEW_REVISIONS_TEMPLATE = """\
 | |
| No new revisions were added by this update.
 | |
| """
 | |
| 
 | |
| 
 | |
| DISCARDED_REVISIONS_TEMPLATE = """\
 | |
| This change permanently discards the following revisions:
 | |
| """
 | |
| 
 | |
| 
 | |
| NO_DISCARDED_REVISIONS_TEMPLATE = """\
 | |
| The revisions that were on this %(refname_type)s are still contained in
 | |
| other references; therefore, this change does not discard any commits
 | |
| from the repository.
 | |
| """
 | |
| 
 | |
| 
 | |
| NEW_REVISIONS_TEMPLATE = """\
 | |
| The %(tot)s revisions listed above as "new" are entirely new to this
 | |
| repository and will be described in separate emails.  The revisions
 | |
| listed as "add" were already present in the repository and have only
 | |
| been added to this reference.
 | |
| 
 | |
| """
 | |
| 
 | |
| 
 | |
| TAG_CREATED_TEMPLATE = """\
 | |
|       at %(newrev_short)-8s (%(newrev_type)s)
 | |
| """
 | |
| 
 | |
| 
 | |
| TAG_UPDATED_TEMPLATE = """\
 | |
| *** WARNING: tag %(short_refname)s was modified! ***
 | |
| 
 | |
|     from %(oldrev_short)-8s (%(oldrev_type)s)
 | |
|       to %(newrev_short)-8s (%(newrev_type)s)
 | |
| """
 | |
| 
 | |
| 
 | |
| TAG_DELETED_TEMPLATE = """\
 | |
| *** WARNING: tag %(short_refname)s was deleted! ***
 | |
| 
 | |
| """
 | |
| 
 | |
| 
 | |
| # The template used in summary tables.  It looks best if this uses the
 | |
| # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
 | |
| BRIEF_SUMMARY_TEMPLATE = """\
 | |
| %(action)8s %(rev_short)-8s %(text)s
 | |
| """
 | |
| 
 | |
| 
 | |
| NON_COMMIT_UPDATE_TEMPLATE = """\
 | |
| This is an unusual reference change because the reference did not
 | |
| refer to a commit either before or after the change.  We do not know
 | |
| how to provide full information about this reference change.
 | |
| """
 | |
| 
 | |
| 
 | |
| REVISION_HEADER_TEMPLATE = """\
 | |
| Date: %(send_date)s
 | |
| To: %(recipients)s
 | |
| Cc: %(cc_recipients)s
 | |
| Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
 | |
| MIME-Version: 1.0
 | |
| Content-Type: text/%(contenttype)s; charset=%(charset)s
 | |
| Content-Transfer-Encoding: 8bit
 | |
| From: %(fromaddr)s
 | |
| Reply-To: %(reply_to)s
 | |
| In-Reply-To: %(reply_to_msgid)s
 | |
| References: %(reply_to_msgid)s
 | |
| Thread-Index: %(thread_index)s
 | |
| X-Git-Host: %(fqdn)s
 | |
| X-Git-Repo: %(repo_shortname)s
 | |
| X-Git-Refname: %(refname)s
 | |
| X-Git-Reftype: %(refname_type)s
 | |
| X-Git-Rev: %(rev)s
 | |
| X-Git-NotificationType: diff
 | |
| X-Git-Multimail-Version: %(multimail_version)s
 | |
| Auto-Submitted: auto-generated
 | |
| """
 | |
| 
 | |
| REVISION_INTRO_TEMPLATE = """\
 | |
| This is an automated email from the git hooks/post-receive script.
 | |
| 
 | |
| %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
 | |
| in repository %(repo_shortname)s.
 | |
| 
 | |
| """
 | |
| 
 | |
| LINK_TEXT_TEMPLATE = """\
 | |
| View the commit online:
 | |
| %(browse_url)s
 | |
| 
 | |
| """
 | |
| 
 | |
| LINK_HTML_TEMPLATE = """\
 | |
| <p><a href="%(browse_url)s">View the commit online</a>.</p>
 | |
| """
 | |
| 
 | |
| 
 | |
| REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
 | |
| 
 | |
| 
 | |
| # Combined, meaning refchange+revision email (for single-commit additions)
 | |
| COMBINED_HEADER_TEMPLATE = """\
 | |
| Date: %(send_date)s
 | |
| To: %(recipients)s
 | |
| Subject: %(subject)s
 | |
| MIME-Version: 1.0
 | |
| Content-Type: text/%(contenttype)s; charset=%(charset)s
 | |
| Content-Transfer-Encoding: 8bit
 | |
| Message-ID: %(msgid)s
 | |
| From: %(fromaddr)s
 | |
| Reply-To: %(reply_to)s
 | |
| X-Git-Host: %(fqdn)s
 | |
| X-Git-Repo: %(repo_shortname)s
 | |
| X-Git-Refname: %(refname)s
 | |
| X-Git-Reftype: %(refname_type)s
 | |
| X-Git-Oldrev: %(oldrev)s
 | |
| X-Git-Newrev: %(newrev)s
 | |
| X-Git-Rev: %(rev)s
 | |
| X-Git-NotificationType: ref_changed_plus_diff
 | |
| X-Git-Multimail-Version: %(multimail_version)s
 | |
| Auto-Submitted: auto-generated
 | |
| """
 | |
| 
 | |
| COMBINED_INTRO_TEMPLATE = """\
 | |
| This is an automated email from the git hooks/post-receive script.
 | |
| 
 | |
| %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
 | |
| in repository %(repo_shortname)s.
 | |
| 
 | |
| """
 | |
| 
 | |
| COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
 | |
| 
 | |
| 
 | |
| class CommandError(Exception):
 | |
|     def __init__(self, cmd, retcode):
 | |
|         self.cmd = cmd
 | |
|         self.retcode = retcode
 | |
|         Exception.__init__(
 | |
|             self,
 | |
|             'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
 | |
|             )
 | |
| 
 | |
| 
 | |
| class ConfigurationException(Exception):
 | |
|     pass
 | |
| 
 | |
| 
 | |
| # The "git" program (this could be changed to include a full path):
 | |
| GIT_EXECUTABLE = 'git'
 | |
| 
 | |
| 
 | |
| # How "git" should be invoked (including global arguments), as a list
 | |
| # of words.  This variable is usually initialized automatically by
 | |
| # read_git_output() via choose_git_command(), but if a value is set
 | |
| # here then it will be used unconditionally.
 | |
| GIT_CMD = None
 | |
| 
 | |
| 
 | |
| def choose_git_command():
 | |
|     """Decide how to invoke git, and record the choice in GIT_CMD."""
 | |
| 
 | |
|     global GIT_CMD
 | |
| 
 | |
|     if GIT_CMD is None:
 | |
|         try:
 | |
|             # Check to see whether the "-c" option is accepted (it was
 | |
|             # only added in Git 1.7.2).  We don't actually use the
 | |
|             # output of "git --version", though if we needed more
 | |
|             # specific version information this would be the place to
 | |
|             # do it.
 | |
|             cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
 | |
|             read_output(cmd)
 | |
|             GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
 | |
|         except CommandError:
 | |
|             GIT_CMD = [GIT_EXECUTABLE]
 | |
| 
 | |
| 
 | |
| def read_git_output(args, input=None, keepends=False, **kw):
 | |
|     """Read the output of a Git command."""
 | |
| 
 | |
|     if GIT_CMD is None:
 | |
|         choose_git_command()
 | |
| 
 | |
|     return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
 | |
| 
 | |
| 
 | |
| def read_output(cmd, input=None, keepends=False, **kw):
 | |
|     if input:
 | |
|         stdin = subprocess.PIPE
 | |
|         input = str_to_bytes(input)
 | |
|     else:
 | |
|         stdin = None
 | |
|     errors = 'strict'
 | |
|     if 'errors' in kw:
 | |
|         errors = kw['errors']
 | |
|         del kw['errors']
 | |
|     p = subprocess.Popen(
 | |
|         tuple(str_to_bytes(w) for w in cmd),
 | |
|         stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
 | |
|         )
 | |
|     (out, err) = p.communicate(input)
 | |
|     out = bytes_to_str(out, errors=errors)
 | |
|     retcode = p.wait()
 | |
|     if retcode:
 | |
|         raise CommandError(cmd, retcode)
 | |
|     if not keepends:
 | |
|         out = out.rstrip('\n\r')
 | |
|     return out
 | |
| 
 | |
| 
 | |
| def read_git_lines(args, keepends=False, **kw):
 | |
|     """Return the lines output by Git command.
 | |
| 
 | |
|     Return as single lines, with newlines stripped off."""
 | |
| 
 | |
|     return read_git_output(args, keepends=True, **kw).splitlines(keepends)
 | |
| 
 | |
| 
 | |
| def git_rev_list_ish(cmd, spec, args=None, **kw):
 | |
|     """Common functionality for invoking a 'git rev-list'-like command.
 | |
| 
 | |
|     Parameters:
 | |
|       * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
 | |
|       * spec is a list of revision arguments to pass to the named
 | |
|         command.  If None, this function returns an empty list.
 | |
|       * args is a list of extra arguments passed to the named command.
 | |
|       * All other keyword arguments (if any) are passed to the
 | |
|         underlying read_git_lines() function.
 | |
| 
 | |
|     Return the output of the Git command in the form of a list, one
 | |
|     entry per output line.
 | |
|     """
 | |
|     if spec is None:
 | |
|         return []
 | |
|     if args is None:
 | |
|         args = []
 | |
|     args = [cmd, '--stdin'] + args
 | |
|     spec_stdin = ''.join(s + '\n' for s in spec)
 | |
|     return read_git_lines(args, input=spec_stdin, **kw)
 | |
| 
 | |
| 
 | |
| def git_rev_list(spec, **kw):
 | |
|     """Run 'git rev-list' with the given list of revision arguments.
 | |
| 
 | |
|     See git_rev_list_ish() for parameter and return value
 | |
|     documentation.
 | |
|     """
 | |
|     return git_rev_list_ish('rev-list', spec, **kw)
 | |
| 
 | |
| 
 | |
| def git_log(spec, **kw):
 | |
|     """Run 'git log' with the given list of revision arguments.
 | |
| 
 | |
|     See git_rev_list_ish() for parameter and return value
 | |
|     documentation.
 | |
|     """
 | |
|     return git_rev_list_ish('log', spec, **kw)
 | |
| 
 | |
| 
 | |
| def header_encode(text, header_name=None):
 | |
|     """Encode and line-wrap the value of an email header field."""
 | |
| 
 | |
|     # Convert to unicode, if required.
 | |
|     if not isinstance(text, unicode):
 | |
|         text = unicode(text, 'utf-8')
 | |
| 
 | |
|     if is_ascii(text):
 | |
|         charset = 'ascii'
 | |
|     else:
 | |
|         charset = 'utf-8'
 | |
| 
 | |
|     return Header(text, header_name=header_name, charset=Charset(charset)).encode()
 | |
| 
 | |
| 
 | |
| def addr_header_encode(text, header_name=None):
 | |
|     """Encode and line-wrap the value of an email header field containing
 | |
|     email addresses."""
 | |
| 
 | |
|     # Convert to unicode, if required.
 | |
|     if not isinstance(text, unicode):
 | |
|         text = unicode(text, 'utf-8')
 | |
| 
 | |
|     text = ', '.join(
 | |
|         formataddr((header_encode(name), emailaddr))
 | |
|         for name, emailaddr in getaddresses([text])
 | |
|         )
 | |
| 
 | |
|     if is_ascii(text):
 | |
|         charset = 'ascii'
 | |
|     else:
 | |
|         charset = 'utf-8'
 | |
| 
 | |
|     return Header(text, header_name=header_name, charset=Charset(charset)).encode()
 | |
| 
 | |
| 
 | |
| class Config(object):
 | |
|     def __init__(self, section, git_config=None):
 | |
|         """Represent a section of the git configuration.
 | |
| 
 | |
|         If git_config is specified, it is passed to "git config" in
 | |
|         the GIT_CONFIG environment variable, meaning that "git config"
 | |
|         will read the specified path rather than the Git default
 | |
|         config paths."""
 | |
| 
 | |
|         self.section = section
 | |
|         if git_config:
 | |
|             self.env = os.environ.copy()
 | |
|             self.env['GIT_CONFIG'] = git_config
 | |
|         else:
 | |
|             self.env = None
 | |
| 
 | |
|     @staticmethod
 | |
|     def _split(s):
 | |
|         """Split NUL-terminated values."""
 | |
| 
 | |
|         words = s.split('\0')
 | |
|         assert words[-1] == ''
 | |
|         return words[:-1]
 | |
| 
 | |
|     @staticmethod
 | |
|     def add_config_parameters(c):
 | |
|         """Add configuration parameters to Git.
 | |
| 
 | |
|         c is either an str or a list of str, each element being of the
 | |
|         form 'var=val' or 'var', with the same syntax and meaning as
 | |
|         the argument of 'git -c var=val'.
 | |
|         """
 | |
|         if isinstance(c, str):
 | |
|             c = (c,)
 | |
|         parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
 | |
|         if parameters:
 | |
|             parameters += ' '
 | |
|         # git expects GIT_CONFIG_PARAMETERS to be of the form
 | |
|         #    "'name1=value1' 'name2=value2' 'name3=value3'"
 | |
|         # including everything inside the double quotes (but not the double
 | |
|         # quotes themselves).  Spacing is critical.  Also, if a value contains
 | |
|         # a literal single quote that quote must be represented using the
 | |
|         # four character sequence: '\''
 | |
|         parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
 | |
|         os.environ['GIT_CONFIG_PARAMETERS'] = parameters
 | |
| 
 | |
|     def get(self, name, default=None):
 | |
|         try:
 | |
|             values = self._split(read_git_output(
 | |
|                 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
 | |
|                 env=self.env, keepends=True,
 | |
|                 ))
 | |
|             assert len(values) == 1
 | |
|             return values[0]
 | |
|         except CommandError:
 | |
|             return default
 | |
| 
 | |
|     def get_bool(self, name, default=None):
 | |
|         try:
 | |
|             value = read_git_output(
 | |
|                 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
 | |
|                 env=self.env,
 | |
|                 )
 | |
|         except CommandError:
 | |
|             return default
 | |
|         return value == 'true'
 | |
| 
 | |
|     def get_all(self, name, default=None):
 | |
|         """Read a (possibly multivalued) setting from the configuration.
 | |
| 
 | |
|         Return the result as a list of values, or default if the name
 | |
|         is unset."""
 | |
| 
 | |
|         try:
 | |
|             return self._split(read_git_output(
 | |
|                 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
 | |
|                 env=self.env, keepends=True,
 | |
|                 ))
 | |
|         except CommandError:
 | |
|             t, e, traceback = sys.exc_info()
 | |
|             if e.retcode == 1:
 | |
|                 # "the section or key is invalid"; i.e., there is no
 | |
|                 # value for the specified key.
 | |
|                 return default
 | |
|             else:
 | |
|                 raise
 | |
| 
 | |
|     def set(self, name, value):
 | |
|         read_git_output(
 | |
|             ['config', '%s.%s' % (self.section, name), value],
 | |
|             env=self.env,
 | |
|             )
 | |
| 
 | |
|     def add(self, name, value):
 | |
|         read_git_output(
 | |
|             ['config', '--add', '%s.%s' % (self.section, name), value],
 | |
|             env=self.env,
 | |
|             )
 | |
| 
 | |
|     def __contains__(self, name):
 | |
|         return self.get_all(name, default=None) is not None
 | |
| 
 | |
|     # We don't use this method anymore internally, but keep it here in
 | |
|     # case somebody is calling it from their own code:
 | |
|     def has_key(self, name):
 | |
|         return name in self
 | |
| 
 | |
|     def unset_all(self, name):
 | |
|         try:
 | |
|             read_git_output(
 | |
|                 ['config', '--unset-all', '%s.%s' % (self.section, name)],
 | |
|                 env=self.env,
 | |
|                 )
 | |
|         except CommandError:
 | |
|             t, e, traceback = sys.exc_info()
 | |
|             if e.retcode == 5:
 | |
|                 # The name doesn't exist, which is what we wanted anyway...
 | |
|                 pass
 | |
|             else:
 | |
|                 raise
 | |
| 
 | |
|     def set_recipients(self, name, value):
 | |
|         self.unset_all(name)
 | |
|         for pair in getaddresses([value]):
 | |
|             self.add(name, formataddr(pair))
 | |
| 
 | |
| 
 | |
| def generate_summaries(*log_args):
 | |
|     """Generate a brief summary for each revision requested.
 | |
| 
 | |
|     log_args are strings that will be passed directly to "git log" as
 | |
|     revision selectors.  Iterate over (sha1_short, subject) for each
 | |
|     commit specified by log_args (subject is the first line of the
 | |
|     commit message as a string without EOLs)."""
 | |
| 
 | |
|     cmd = [
 | |
|         'log', '--abbrev', '--format=%h %s',
 | |
|         ] + list(log_args) + ['--']
 | |
|     for line in read_git_lines(cmd):
 | |
|         yield tuple(line.split(' ', 1))
 | |
| 
 | |
| 
 | |
| def limit_lines(lines, max_lines):
 | |
|     for (index, line) in enumerate(lines):
 | |
|         if index < max_lines:
 | |
|             yield line
 | |
| 
 | |
|     if index >= max_lines:
 | |
|         yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
 | |
| 
 | |
| 
 | |
| def limit_linelength(lines, max_linelength):
 | |
|     for line in lines:
 | |
|         # Don't forget that lines always include a trailing newline.
 | |
|         if len(line) > max_linelength + 1:
 | |
|             line = line[:max_linelength - 7] + ' [...]\n'
 | |
|         yield line
 | |
| 
 | |
| 
 | |
| class CommitSet(object):
 | |
|     """A (constant) set of object names.
 | |
| 
 | |
|     The set should be initialized with full SHA1 object names.  The
 | |
|     __contains__() method returns True iff its argument is an
 | |
|     abbreviation of any the names in the set."""
 | |
| 
 | |
|     def __init__(self, names):
 | |
|         self._names = sorted(names)
 | |
| 
 | |
|     def __len__(self):
 | |
|         return len(self._names)
 | |
| 
 | |
|     def __contains__(self, sha1_abbrev):
 | |
|         """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
 | |
| 
 | |
|         i = bisect.bisect_left(self._names, sha1_abbrev)
 | |
|         return i < len(self) and self._names[i].startswith(sha1_abbrev)
 | |
| 
 | |
| 
 | |
| class GitObject(object):
 | |
|     def __init__(self, sha1, type=None):
 | |
|         if sha1 == ZEROS:
 | |
|             self.sha1 = self.type = self.commit_sha1 = None
 | |
|         else:
 | |
|             self.sha1 = sha1
 | |
|             self.type = type or read_git_output(['cat-file', '-t', self.sha1])
 | |
| 
 | |
|             if self.type == 'commit':
 | |
|                 self.commit_sha1 = self.sha1
 | |
|             elif self.type == 'tag':
 | |
|                 try:
 | |
|                     self.commit_sha1 = read_git_output(
 | |
|                         ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
 | |
|                         )
 | |
|                 except CommandError:
 | |
|                     # Cannot deref tag to determine commit_sha1
 | |
|                     self.commit_sha1 = None
 | |
|             else:
 | |
|                 self.commit_sha1 = None
 | |
| 
 | |
|         self.short = read_git_output(['rev-parse', '--short', sha1])
 | |
| 
 | |
|     def get_summary(self):
 | |
|         """Return (sha1_short, subject) for this commit."""
 | |
| 
 | |
|         if not self.sha1:
 | |
|             raise ValueError('Empty commit has no summary')
 | |
| 
 | |
|         return next(iter(generate_summaries('--no-walk', self.sha1)))
 | |
| 
 | |
|     def __eq__(self, other):
 | |
|         return isinstance(other, GitObject) and self.sha1 == other.sha1
 | |
| 
 | |
|     def __ne__(self, other):
 | |
|         return not self == other
 | |
| 
 | |
|     def __hash__(self):
 | |
|         return hash(self.sha1)
 | |
| 
 | |
|     def __nonzero__(self):
 | |
|         return bool(self.sha1)
 | |
| 
 | |
|     def __bool__(self):
 | |
|         """Python 2 backward compatibility"""
 | |
|         return self.__nonzero__()
 | |
| 
 | |
|     def __str__(self):
 | |
|         return self.sha1 or ZEROS
 | |
| 
 | |
| 
 | |
| class Change(object):
 | |
|     """A Change that has been made to the Git repository.
 | |
| 
 | |
|     Abstract class from which both Revisions and ReferenceChanges are
 | |
|     derived.  A Change knows how to generate a notification email
 | |
|     describing itself."""
 | |
| 
 | |
|     def __init__(self, environment):
 | |
|         self.environment = environment
 | |
|         self._values = None
 | |
|         self._contains_html_diff = False
 | |
| 
 | |
|     def _contains_diff(self):
 | |
|         # We do contain a diff, should it be rendered in HTML?
 | |
|         if self.environment.commit_email_format == "html":
 | |
|             self._contains_html_diff = True
 | |
| 
 | |
|     def _compute_values(self):
 | |
|         """Return a dictionary {keyword: expansion} for this Change.
 | |
| 
 | |
|         Derived classes overload this method to add more entries to
 | |
|         the return value.  This method is used internally by
 | |
|         get_values().  The return value should always be a new
 | |
|         dictionary."""
 | |
| 
 | |
|         values = self.environment.get_values()
 | |
|         fromaddr = self.environment.get_fromaddr(change=self)
 | |
|         if fromaddr is not None:
 | |
|             values['fromaddr'] = fromaddr
 | |
|         values['multimail_version'] = get_version()
 | |
|         return values
 | |
| 
 | |
|     # Aliases usable in template strings. Tuple of pairs (destination,
 | |
|     # source).
 | |
|     VALUES_ALIAS = (
 | |
|         ("id", "newrev"),
 | |
|         )
 | |
| 
 | |
|     def get_values(self, **extra_values):
 | |
|         """Return a dictionary {keyword: expansion} for this Change.
 | |
| 
 | |
|         Return a dictionary mapping keywords to the values that they
 | |
|         should be expanded to for this Change (used when interpolating
 | |
|         template strings).  If any keyword arguments are supplied, add
 | |
|         those to the return value as well.  The return value is always
 | |
|         a new dictionary."""
 | |
| 
 | |
|         if self._values is None:
 | |
|             self._values = self._compute_values()
 | |
| 
 | |
|         values = self._values.copy()
 | |
|         if extra_values:
 | |
|             values.update(extra_values)
 | |
| 
 | |
|         for alias, val in self.VALUES_ALIAS:
 | |
|             values[alias] = values[val]
 | |
|         return values
 | |
| 
 | |
|     def expand(self, template, **extra_values):
 | |
|         """Expand template.
 | |
| 
 | |
|         Expand the template (which should be a string) using string
 | |
|         interpolation of the values for this Change.  If any keyword
 | |
|         arguments are provided, also include those in the keywords
 | |
|         available for interpolation."""
 | |
| 
 | |
|         return template % self.get_values(**extra_values)
 | |
| 
 | |
|     def expand_lines(self, template, html_escape_val=False, **extra_values):
 | |
|         """Break template into lines and expand each line."""
 | |
| 
 | |
|         values = self.get_values(**extra_values)
 | |
|         if html_escape_val:
 | |
|             for k in values:
 | |
|                 if is_string(values[k]):
 | |
|                     values[k] = html_escape(values[k])
 | |
|         for line in template.splitlines(True):
 | |
|             yield line % values
 | |
| 
 | |
|     def expand_header_lines(self, template, **extra_values):
 | |
|         """Break template into lines and expand each line as an RFC 2822 header.
 | |
| 
 | |
|         Encode values and split up lines that are too long.  Silently
 | |
|         skip lines that contain references to unknown variables."""
 | |
| 
 | |
|         values = self.get_values(**extra_values)
 | |
|         if self._contains_html_diff:
 | |
|             self._content_type = 'html'
 | |
|         else:
 | |
|             self._content_type = 'plain'
 | |
|         values['contenttype'] = self._content_type
 | |
| 
 | |
|         for line in template.splitlines():
 | |
|             (name, value) = line.split(': ', 1)
 | |
| 
 | |
|             try:
 | |
|                 value = value % values
 | |
|             except KeyError:
 | |
|                 t, e, traceback = sys.exc_info()
 | |
|                 if DEBUG:
 | |
|                     self.environment.log_warning(
 | |
|                         'Warning: unknown variable %r in the following line; line skipped:\n'
 | |
|                         '    %s\n'
 | |
|                         % (e.args[0], line,)
 | |
|                         )
 | |
|             else:
 | |
|                 if name.lower() in ADDR_HEADERS:
 | |
|                     value = addr_header_encode(value, name)
 | |
|                 else:
 | |
|                     value = header_encode(value, name)
 | |
|                 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
 | |
|                     yield splitline
 | |
| 
 | |
|     def generate_email_header(self):
 | |
|         """Generate the RFC 2822 email headers for this Change, a line at a time.
 | |
| 
 | |
|         The output should not include the trailing blank line."""
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def generate_browse_link(self, base_url):
 | |
|         """Generate a link to an online repository browser."""
 | |
|         return iter(())
 | |
| 
 | |
|     def generate_email_intro(self, html_escape_val=False):
 | |
|         """Generate the email intro for this Change, a line at a time.
 | |
| 
 | |
|         The output will be used as the standard boilerplate at the top
 | |
|         of the email body."""
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def generate_email_body(self, push):
 | |
|         """Generate the main part of the email body, a line at a time.
 | |
| 
 | |
|         The text in the body might be truncated after a specified
 | |
|         number of lines (see multimailhook.emailmaxlines)."""
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def generate_email_footer(self, html_escape_val):
 | |
|         """Generate the footer of the email, a line at a time.
 | |
| 
 | |
|         The footer is always included, irrespective of
 | |
|         multimailhook.emailmaxlines."""
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def _wrap_for_html(self, lines):
 | |
|         """Wrap the lines in HTML <pre> tag when using HTML format.
 | |
| 
 | |
|         Escape special HTML characters and add <pre> and </pre> tags around
 | |
|         the given lines if we should be generating HTML as indicated by
 | |
|         self._contains_html_diff being set to true.
 | |
|         """
 | |
|         if self._contains_html_diff:
 | |
|             yield "<pre style='margin:0'>\n"
 | |
| 
 | |
|             for line in lines:
 | |
|                 yield html_escape(line)
 | |
| 
 | |
|             yield '</pre>\n'
 | |
|         else:
 | |
|             for line in lines:
 | |
|                 yield line
 | |
| 
 | |
|     def generate_email(self, push, body_filter=None, extra_header_values={}):
 | |
|         """Generate an email describing this change.
 | |
| 
 | |
|         Iterate over the lines (including the header lines) of an
 | |
|         email describing this change.  If body_filter is not None,
 | |
|         then use it to filter the lines that are intended for the
 | |
|         email body.
 | |
| 
 | |
|         The extra_header_values field is received as a dict and not as
 | |
|         **kwargs, to allow passing other keyword arguments in the
 | |
|         future (e.g. passing extra values to generate_email_intro()"""
 | |
| 
 | |
|         for line in self.generate_email_header(**extra_header_values):
 | |
|             yield line
 | |
|         yield '\n'
 | |
|         html_escape_val = (self.environment.html_in_intro and
 | |
|                            self._contains_html_diff)
 | |
|         intro = self.generate_email_intro(html_escape_val)
 | |
|         if not self.environment.html_in_intro:
 | |
|             intro = self._wrap_for_html(intro)
 | |
|         for line in intro:
 | |
|             yield line
 | |
| 
 | |
|         if self.environment.commitBrowseURL:
 | |
|             for line in self.generate_browse_link(self.environment.commitBrowseURL):
 | |
|                 yield line
 | |
| 
 | |
|         body = self.generate_email_body(push)
 | |
|         if body_filter is not None:
 | |
|             body = body_filter(body)
 | |
| 
 | |
|         diff_started = False
 | |
|         if self._contains_html_diff:
 | |
|             # "white-space: pre" is the default, but we need to
 | |
|             # specify it again in case the message is viewed in a
 | |
|             # webmail which wraps it in an element setting white-space
 | |
|             # to something else (Zimbra does this and sets
 | |
|             # white-space: pre-line).
 | |
|             yield '<pre style="white-space: pre; background: #F8F8F8">'
 | |
|         for line in body:
 | |
|             if self._contains_html_diff:
 | |
|                 # This is very, very naive. It would be much better to really
 | |
|                 # parse the diff, i.e. look at how many lines do we have in
 | |
|                 # the hunk headers instead of blindly highlighting everything
 | |
|                 # that looks like it might be part of a diff.
 | |
|                 bgcolor = ''
 | |
|                 fgcolor = ''
 | |
|                 if line.startswith('--- a/'):
 | |
|                     diff_started = True
 | |
|                     bgcolor = 'e0e0ff'
 | |
|                 elif line.startswith('diff ') or line.startswith('index '):
 | |
|                     diff_started = True
 | |
|                     fgcolor = '808080'
 | |
|                 elif diff_started:
 | |
|                     if line.startswith('+++ '):
 | |
|                         bgcolor = 'e0e0ff'
 | |
|                     elif line.startswith('@@'):
 | |
|                         bgcolor = 'e0e0e0'
 | |
|                     elif line.startswith('+'):
 | |
|                         bgcolor = 'e0ffe0'
 | |
|                     elif line.startswith('-'):
 | |
|                         bgcolor = 'ffe0e0'
 | |
|                 elif line.startswith('commit '):
 | |
|                     fgcolor = '808000'
 | |
|                 elif line.startswith('    '):
 | |
|                     fgcolor = '404040'
 | |
| 
 | |
|                 # Chop the trailing LF, we don't want it inside <pre>.
 | |
|                 line = html_escape(line[:-1])
 | |
| 
 | |
|                 if bgcolor or fgcolor:
 | |
|                     style = 'display:block; white-space:pre;'
 | |
|                     if bgcolor:
 | |
|                         style += 'background:#' + bgcolor + ';'
 | |
|                     if fgcolor:
 | |
|                         style += 'color:#' + fgcolor + ';'
 | |
|                     # Use a <span style='display:block> to color the
 | |
|                     # whole line. The newline must be inside the span
 | |
|                     # to display properly both in Firefox and in
 | |
|                     # text-based browser.
 | |
|                     line = "<span style='%s'>%s\n</span>" % (style, line)
 | |
|                 else:
 | |
|                     line = line + '\n'
 | |
| 
 | |
|             yield line
 | |
|         if self._contains_html_diff:
 | |
|             yield '</pre>'
 | |
|         html_escape_val = (self.environment.html_in_footer and
 | |
|                            self._contains_html_diff)
 | |
|         footer = self.generate_email_footer(html_escape_val)
 | |
|         if not self.environment.html_in_footer:
 | |
|             footer = self._wrap_for_html(footer)
 | |
|         for line in footer:
 | |
|             yield line
 | |
| 
 | |
|     def get_specific_fromaddr(self):
 | |
|         """For kinds of Changes which specify it, return the kind-specific
 | |
|         From address to use."""
 | |
|         return None
 | |
| 
 | |
| 
 | |
| class Revision(Change):
 | |
|     """A Change consisting of a single git commit."""
 | |
| 
 | |
|     CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
 | |
| 
 | |
|     def __init__(self, reference_change, rev, num, tot):
 | |
|         Change.__init__(self, reference_change.environment)
 | |
|         self.reference_change = reference_change
 | |
|         self.rev = rev
 | |
|         self.change_type = self.reference_change.change_type
 | |
|         self.refname = self.reference_change.refname
 | |
|         self.num = num
 | |
|         self.tot = tot
 | |
|         self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
 | |
|         self.recipients = self.environment.get_revision_recipients(self)
 | |
| 
 | |
|         # -s is short for --no-patch, but -s works on older git's (e.g. 1.7)
 | |
|         self.parents = read_git_lines(['show', '-s', '--format=%P',
 | |
|                                       self.rev.sha1])[0].split()
 | |
| 
 | |
|         self.cc_recipients = ''
 | |
|         if self.environment.get_scancommitforcc():
 | |
|             self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
 | |
|             if self.cc_recipients:
 | |
|                 self.environment.log_msg(
 | |
|                     'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1))
 | |
| 
 | |
|     def _cc_recipients(self):
 | |
|         cc_recipients = []
 | |
|         message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
 | |
|         lines = message.strip().split('\n')
 | |
|         for line in lines:
 | |
|             m = re.match(self.CC_RE, line)
 | |
|             if m:
 | |
|                 cc_recipients.append(m.group('to'))
 | |
| 
 | |
|         return cc_recipients
 | |
| 
 | |
|     def _compute_values(self):
 | |
|         values = Change._compute_values(self)
 | |
| 
 | |
|         oneline = read_git_output(
 | |
|             ['log', '--format=%s', '--no-walk', self.rev.sha1]
 | |
|             )
 | |
| 
 | |
|         max_subject_length = self.environment.get_max_subject_length()
 | |
|         if max_subject_length > 0 and len(oneline) > max_subject_length:
 | |
|             oneline = oneline[:max_subject_length - 6] + ' [...]'
 | |
| 
 | |
|         values['rev'] = self.rev.sha1
 | |
|         values['parents'] = ' '.join(self.parents)
 | |
|         values['rev_short'] = self.rev.short
 | |
|         values['change_type'] = self.change_type
 | |
|         values['refname'] = self.refname
 | |
|         values['newrev'] = self.rev.sha1
 | |
|         values['short_refname'] = self.reference_change.short_refname
 | |
|         values['refname_type'] = self.reference_change.refname_type
 | |
|         values['reply_to_msgid'] = self.reference_change.msgid
 | |
|         values['thread_index'] = self.reference_change.thread_index
 | |
|         values['num'] = self.num
 | |
|         values['tot'] = self.tot
 | |
|         values['recipients'] = self.recipients
 | |
|         if self.cc_recipients:
 | |
|             values['cc_recipients'] = self.cc_recipients
 | |
|         values['oneline'] = oneline
 | |
|         values['author'] = self.author
 | |
| 
 | |
|         reply_to = self.environment.get_reply_to_commit(self)
 | |
|         if reply_to:
 | |
|             values['reply_to'] = reply_to
 | |
| 
 | |
|         return values
 | |
| 
 | |
|     def generate_email_header(self, **extra_values):
 | |
|         for line in self.expand_header_lines(
 | |
|                 REVISION_HEADER_TEMPLATE, **extra_values
 | |
|                 ):
 | |
|             yield line
 | |
| 
 | |
|     def generate_browse_link(self, base_url):
 | |
|         if '%(' not in base_url:
 | |
|             base_url += '%(id)s'
 | |
|         url = "".join(self.expand_lines(base_url))
 | |
|         if self._content_type == 'html':
 | |
|             for line in self.expand_lines(LINK_HTML_TEMPLATE,
 | |
|                                           html_escape_val=True,
 | |
|                                           browse_url=url):
 | |
|                 yield line
 | |
|         elif self._content_type == 'plain':
 | |
|             for line in self.expand_lines(LINK_TEXT_TEMPLATE,
 | |
|                                           html_escape_val=False,
 | |
|                                           browse_url=url):
 | |
|                 yield line
 | |
|         else:
 | |
|             raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
 | |
| 
 | |
|     def generate_email_intro(self, html_escape_val=False):
 | |
|         for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
 | |
|                                       html_escape_val=html_escape_val):
 | |
|             yield line
 | |
| 
 | |
|     def generate_email_body(self, push):
 | |
|         """Show this revision."""
 | |
| 
 | |
|         for line in read_git_lines(
 | |
|                 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
 | |
|                 keepends=True,
 | |
|                 errors='replace'):
 | |
|             if line.startswith('Date:   ') and self.environment.date_substitute:
 | |
|                 yield self.environment.date_substitute + line[len('Date:   '):]
 | |
|             else:
 | |
|                 yield line
 | |
| 
 | |
|     def generate_email_footer(self, html_escape_val):
 | |
|         return self.expand_lines(REVISION_FOOTER_TEMPLATE,
 | |
|                                  html_escape_val=html_escape_val)
 | |
| 
 | |
|     def generate_email(self, push, body_filter=None, extra_header_values={}):
 | |
|         self._contains_diff()
 | |
|         return Change.generate_email(self, push, body_filter, extra_header_values)
 | |
| 
 | |
|     def get_specific_fromaddr(self):
 | |
|         return self.environment.from_commit
 | |
| 
 | |
| 
 | |
| class ReferenceChange(Change):
 | |
|     """A Change to a Git reference.
 | |
| 
 | |
|     An abstract class representing a create, update, or delete of a
 | |
|     Git reference.  Derived classes handle specific types of reference
 | |
|     (e.g., tags vs. branches).  These classes generate the main
 | |
|     reference change email summarizing the reference change and
 | |
|     whether it caused any any commits to be added or removed.
 | |
| 
 | |
|     ReferenceChange objects are usually created using the static
 | |
|     create() method, which has the logic to decide which derived class
 | |
|     to instantiate."""
 | |
| 
 | |
|     REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
 | |
| 
 | |
|     @staticmethod
 | |
|     def create(environment, oldrev, newrev, refname):
 | |
|         """Return a ReferenceChange object representing the change.
 | |
| 
 | |
|         Return an object that represents the type of change that is being
 | |
|         made. oldrev and newrev should be SHA1s or ZEROS."""
 | |
| 
 | |
|         old = GitObject(oldrev)
 | |
|         new = GitObject(newrev)
 | |
|         rev = new or old
 | |
| 
 | |
|         # The revision type tells us what type the commit is, combined with
 | |
|         # the location of the ref we can decide between
 | |
|         #  - working branch
 | |
|         #  - tracking branch
 | |
|         #  - unannotated tag
 | |
|         #  - annotated tag
 | |
|         m = ReferenceChange.REF_RE.match(refname)
 | |
|         if m:
 | |
|             area = m.group('area')
 | |
|             short_refname = m.group('shortname')
 | |
|         else:
 | |
|             area = ''
 | |
|             short_refname = refname
 | |
| 
 | |
|         if rev.type == 'tag':
 | |
|             # Annotated tag:
 | |
|             klass = AnnotatedTagChange
 | |
|         elif rev.type == 'commit':
 | |
|             if area == 'tags':
 | |
|                 # Non-annotated tag:
 | |
|                 klass = NonAnnotatedTagChange
 | |
|             elif area == 'heads':
 | |
|                 # Branch:
 | |
|                 klass = BranchChange
 | |
|             elif area == 'remotes':
 | |
|                 # Tracking branch:
 | |
|                 environment.log_warning(
 | |
|                     '*** Push-update of tracking branch %r\n'
 | |
|                     '***  - incomplete email generated.'
 | |
|                     % (refname,)
 | |
|                     )
 | |
|                 klass = OtherReferenceChange
 | |
|             else:
 | |
|                 # Some other reference namespace:
 | |
|                 environment.log_warning(
 | |
|                     '*** Push-update of strange reference %r\n'
 | |
|                     '***  - incomplete email generated.'
 | |
|                     % (refname,)
 | |
|                     )
 | |
|                 klass = OtherReferenceChange
 | |
|         else:
 | |
|             # Anything else (is there anything else?)
 | |
|             environment.log_warning(
 | |
|                 '*** Unknown type of update to %r (%s)\n'
 | |
|                 '***  - incomplete email generated.'
 | |
|                 % (refname, rev.type,)
 | |
|                 )
 | |
|             klass = OtherReferenceChange
 | |
| 
 | |
|         return klass(
 | |
|             environment,
 | |
|             refname=refname, short_refname=short_refname,
 | |
|             old=old, new=new, rev=rev,
 | |
|             )
 | |
| 
 | |
|     @staticmethod
 | |
|     def make_thread_index():
 | |
|         """Return a string appropriate for the Thread-Index header,
 | |
|         needed by MS Outlook to get threading right.
 | |
| 
 | |
|         The format is (base64-encoded):
 | |
|         - 1 byte must be 1
 | |
|         - 5 bytes encode a date (hardcoded here)
 | |
|         - 16 bytes for a globally unique identifier
 | |
| 
 | |
|         FIXME: Unfortunately, even with the Thread-Index field, MS
 | |
|         Outlook doesn't seem to do the threading reliably (see
 | |
|         https://github.com/git-multimail/git-multimail/pull/194).
 | |
|         """
 | |
|         thread_index = b'\x01\x00\x00\x12\x34\x56' + uuid.uuid4().bytes
 | |
|         return base64.standard_b64encode(thread_index).decode('ascii')
 | |
| 
 | |
|     def __init__(self, environment, refname, short_refname, old, new, rev):
 | |
|         Change.__init__(self, environment)
 | |
|         self.change_type = {
 | |
|             (False, True): 'create',
 | |
|             (True, True): 'update',
 | |
|             (True, False): 'delete',
 | |
|             }[bool(old), bool(new)]
 | |
|         self.refname = refname
 | |
|         self.short_refname = short_refname
 | |
|         self.old = old
 | |
|         self.new = new
 | |
|         self.rev = rev
 | |
|         self.msgid = make_msgid()
 | |
|         self.thread_index = self.make_thread_index()
 | |
|         self.diffopts = environment.diffopts
 | |
|         self.graphopts = environment.graphopts
 | |
|         self.logopts = environment.logopts
 | |
|         self.commitlogopts = environment.commitlogopts
 | |
|         self.showgraph = environment.refchange_showgraph
 | |
|         self.showlog = environment.refchange_showlog
 | |
| 
 | |
|         self.header_template = REFCHANGE_HEADER_TEMPLATE
 | |
|         self.intro_template = REFCHANGE_INTRO_TEMPLATE
 | |
|         self.footer_template = FOOTER_TEMPLATE
 | |
| 
 | |
|     def _compute_values(self):
 | |
|         values = Change._compute_values(self)
 | |
| 
 | |
|         values['change_type'] = self.change_type
 | |
|         values['refname_type'] = self.refname_type
 | |
|         values['refname'] = self.refname
 | |
|         values['short_refname'] = self.short_refname
 | |
|         values['msgid'] = self.msgid
 | |
|         values['thread_index'] = self.thread_index
 | |
|         values['recipients'] = self.recipients
 | |
|         values['oldrev'] = str(self.old)
 | |
|         values['oldrev_short'] = self.old.short
 | |
|         values['newrev'] = str(self.new)
 | |
|         values['newrev_short'] = self.new.short
 | |
| 
 | |
|         if self.old:
 | |
|             values['oldrev_type'] = self.old.type
 | |
|         if self.new:
 | |
|             values['newrev_type'] = self.new.type
 | |
| 
 | |
|         reply_to = self.environment.get_reply_to_refchange(self)
 | |
|         if reply_to:
 | |
|             values['reply_to'] = reply_to
 | |
| 
 | |
|         return values
 | |
| 
 | |
|     def send_single_combined_email(self, known_added_sha1s):
 | |
|         """Determine if a combined refchange/revision email should be sent
 | |
| 
 | |
|         If there is only a single new (non-merge) commit added by a
 | |
|         change, it is useful to combine the ReferenceChange and
 | |
|         Revision emails into one.  In such a case, return the single
 | |
|         revision; otherwise, return None.
 | |
| 
 | |
|         This method is overridden in BranchChange."""
 | |
| 
 | |
|         return None
 | |
| 
 | |
|     def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
 | |
|         """Generate an email describing this change AND specified revision.
 | |
| 
 | |
|         Iterate over the lines (including the header lines) of an
 | |
|         email describing this change.  If body_filter is not None,
 | |
|         then use it to filter the lines that are intended for the
 | |
|         email body.
 | |
| 
 | |
|         The extra_header_values field is received as a dict and not as
 | |
|         **kwargs, to allow passing other keyword arguments in the
 | |
|         future (e.g. passing extra values to generate_email_intro()
 | |
| 
 | |
|         This method is overridden in BranchChange."""
 | |
| 
 | |
|         raise NotImplementedError
 | |
| 
 | |
|     def get_subject(self):
 | |
|         template = {
 | |
|             'create': REF_CREATED_SUBJECT_TEMPLATE,
 | |
|             'update': REF_UPDATED_SUBJECT_TEMPLATE,
 | |
|             'delete': REF_DELETED_SUBJECT_TEMPLATE,
 | |
|             }[self.change_type]
 | |
|         return self.expand(template)
 | |
| 
 | |
|     def generate_email_header(self, **extra_values):
 | |
|         if 'subject' not in extra_values:
 | |
|             extra_values['subject'] = self.get_subject()
 | |
| 
 | |
|         for line in self.expand_header_lines(
 | |
|                 self.header_template, **extra_values
 | |
|                 ):
 | |
|             yield line
 | |
| 
 | |
|     def generate_email_intro(self, html_escape_val=False):
 | |
|         for line in self.expand_lines(self.intro_template,
 | |
|                                       html_escape_val=html_escape_val):
 | |
|             yield line
 | |
| 
 | |
|     def generate_email_body(self, push):
 | |
|         """Call the appropriate body-generation routine.
 | |
| 
 | |
|         Call one of generate_create_summary() /
 | |
|         generate_update_summary() / generate_delete_summary()."""
 | |
| 
 | |
|         change_summary = {
 | |
|             'create': self.generate_create_summary,
 | |
|             'delete': self.generate_delete_summary,
 | |
|             'update': self.generate_update_summary,
 | |
|             }[self.change_type](push)
 | |
|         for line in change_summary:
 | |
|             yield line
 | |
| 
 | |
|         for line in self.generate_revision_change_summary(push):
 | |
|             yield line
 | |
| 
 | |
|     def generate_email_footer(self, html_escape_val):
 | |
|         return self.expand_lines(self.footer_template,
 | |
|                                  html_escape_val=html_escape_val)
 | |
| 
 | |
|     def generate_revision_change_graph(self, push):
 | |
|         if self.showgraph:
 | |
|             args = ['--graph'] + self.graphopts
 | |
|             for newold in ('new', 'old'):
 | |
|                 has_newold = False
 | |
|                 spec = push.get_commits_spec(newold, self)
 | |
|                 for line in git_log(spec, args=args, keepends=True):
 | |
|                     if not has_newold:
 | |
|                         has_newold = True
 | |
|                         yield '\n'
 | |
|                         yield 'Graph of %s commits:\n\n' % (
 | |
|                             {'new': 'new', 'old': 'discarded'}[newold],)
 | |
|                     yield '  ' + line
 | |
|                 if has_newold:
 | |
|                     yield '\n'
 | |
| 
 | |
|     def generate_revision_change_log(self, new_commits_list):
 | |
|         if self.showlog:
 | |
|             yield '\n'
 | |
|             yield 'Detailed log of new commits:\n\n'
 | |
|             for line in read_git_lines(
 | |
|                     ['log', '--no-walk'] +
 | |
|                     self.logopts +
 | |
|                     new_commits_list +
 | |
|                     ['--'],
 | |
|                     keepends=True,
 | |
|                     ):
 | |
|                 yield line
 | |
| 
 | |
|     def generate_new_revision_summary(self, tot, new_commits_list, push):
 | |
|         for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
 | |
|             yield line
 | |
|         for line in self.generate_revision_change_graph(push):
 | |
|             yield line
 | |
|         for line in self.generate_revision_change_log(new_commits_list):
 | |
|             yield line
 | |
| 
 | |
|     def generate_revision_change_summary(self, push):
 | |
|         """Generate a summary of the revisions added/removed by this change."""
 | |
| 
 | |
|         if self.new.commit_sha1 and not self.old.commit_sha1:
 | |
|             # A new reference was created.  List the new revisions
 | |
|             # brought by the new reference (i.e., those revisions that
 | |
|             # were not in the repository before this reference
 | |
|             # change).
 | |
|             sha1s = list(push.get_new_commits(self))
 | |
|             sha1s.reverse()
 | |
|             tot = len(sha1s)
 | |
|             new_revisions = [
 | |
|                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
 | |
|                 for (i, sha1) in enumerate(sha1s)
 | |
|                 ]
 | |
| 
 | |
|             if new_revisions:
 | |
|                 yield self.expand('This %(refname_type)s includes the following new commits:\n')
 | |
|                 yield '\n'
 | |
|                 for r in new_revisions:
 | |
|                     (sha1, subject) = r.rev.get_summary()
 | |
|                     yield r.expand(
 | |
|                         BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
 | |
|                         )
 | |
|                 yield '\n'
 | |
|                 for line in self.generate_new_revision_summary(
 | |
|                         tot, [r.rev.sha1 for r in new_revisions], push):
 | |
|                     yield line
 | |
|             else:
 | |
|                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
 | |
|                     yield line
 | |
| 
 | |
|         elif self.new.commit_sha1 and self.old.commit_sha1:
 | |
|             # A reference was changed to point at a different commit.
 | |
|             # List the revisions that were removed and/or added *from
 | |
|             # that reference* by this reference change, along with a
 | |
|             # diff between the trees for its old and new values.
 | |
| 
 | |
|             # List of the revisions that were added to the branch by
 | |
|             # this update.  Note this list can include revisions that
 | |
|             # have already had notification emails; we want such
 | |
|             # revisions in the summary even though we will not send
 | |
|             # new notification emails for them.
 | |
|             adds = list(generate_summaries(
 | |
|                 '--topo-order', '--reverse', '%s..%s'
 | |
|                 % (self.old.commit_sha1, self.new.commit_sha1,)
 | |
|                 ))
 | |
| 
 | |
|             # List of the revisions that were removed from the branch
 | |
|             # by this update.  This will be empty except for
 | |
|             # non-fast-forward updates.
 | |
|             discards = list(generate_summaries(
 | |
|                 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
 | |
|                 ))
 | |
| 
 | |
|             if adds:
 | |
|                 new_commits_list = push.get_new_commits(self)
 | |
|             else:
 | |
|                 new_commits_list = []
 | |
|             new_commits = CommitSet(new_commits_list)
 | |
| 
 | |
|             if discards:
 | |
|                 discarded_commits = CommitSet(push.get_discarded_commits(self))
 | |
|             else:
 | |
|                 discarded_commits = CommitSet([])
 | |
| 
 | |
|             if discards and adds:
 | |
|                 for (sha1, subject) in discards:
 | |
|                     if sha1 in discarded_commits:
 | |
|                         action = 'discard'
 | |
|                     else:
 | |
|                         action = 'omit'
 | |
|                     yield self.expand(
 | |
|                         BRIEF_SUMMARY_TEMPLATE, action=action,
 | |
|                         rev_short=sha1, text=subject,
 | |
|                         )
 | |
|                 for (sha1, subject) in adds:
 | |
|                     if sha1 in new_commits:
 | |
|                         action = 'new'
 | |
|                     else:
 | |
|                         action = 'add'
 | |
|                     yield self.expand(
 | |
|                         BRIEF_SUMMARY_TEMPLATE, action=action,
 | |
|                         rev_short=sha1, text=subject,
 | |
|                         )
 | |
|                 yield '\n'
 | |
|                 for line in self.expand_lines(NON_FF_TEMPLATE):
 | |
|                     yield line
 | |
| 
 | |
|             elif discards:
 | |
|                 for (sha1, subject) in discards:
 | |
|                     if sha1 in discarded_commits:
 | |
|                         action = 'discard'
 | |
|                     else:
 | |
|                         action = 'omit'
 | |
|                     yield self.expand(
 | |
|                         BRIEF_SUMMARY_TEMPLATE, action=action,
 | |
|                         rev_short=sha1, text=subject,
 | |
|                         )
 | |
|                 yield '\n'
 | |
|                 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
 | |
|                     yield line
 | |
| 
 | |
|             elif adds:
 | |
|                 (sha1, subject) = self.old.get_summary()
 | |
|                 yield self.expand(
 | |
|                     BRIEF_SUMMARY_TEMPLATE, action='from',
 | |
|                     rev_short=sha1, text=subject,
 | |
|                     )
 | |
|                 for (sha1, subject) in adds:
 | |
|                     if sha1 in new_commits:
 | |
|                         action = 'new'
 | |
|                     else:
 | |
|                         action = 'add'
 | |
|                     yield self.expand(
 | |
|                         BRIEF_SUMMARY_TEMPLATE, action=action,
 | |
|                         rev_short=sha1, text=subject,
 | |
|                         )
 | |
| 
 | |
|             yield '\n'
 | |
| 
 | |
|             if new_commits:
 | |
|                 for line in self.generate_new_revision_summary(
 | |
|                         len(new_commits), new_commits_list, push):
 | |
|                     yield line
 | |
|             else:
 | |
|                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
 | |
|                     yield line
 | |
|                 for line in self.generate_revision_change_graph(push):
 | |
|                     yield line
 | |
| 
 | |
|             # The diffstat is shown from the old revision to the new
 | |
|             # revision.  This is to show the truth of what happened in
 | |
|             # this change.  There's no point showing the stat from the
 | |
|             # base to the new revision because the base is effectively a
 | |
|             # random revision at this point - the user will be interested
 | |
|             # in what this revision changed - including the undoing of
 | |
|             # previous revisions in the case of non-fast-forward updates.
 | |
|             yield '\n'
 | |
|             yield 'Summary of changes:\n'
 | |
|             for line in read_git_lines(
 | |
|                     ['diff-tree'] +
 | |
|                     self.diffopts +
 | |
|                     ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
 | |
|                     keepends=True,
 | |
|                     ):
 | |
|                 yield line
 | |
| 
 | |
|         elif self.old.commit_sha1 and not self.new.commit_sha1:
 | |
|             # A reference was deleted.  List the revisions that were
 | |
|             # removed from the repository by this reference change.
 | |
| 
 | |
|             sha1s = list(push.get_discarded_commits(self))
 | |
|             tot = len(sha1s)
 | |
|             discarded_revisions = [
 | |
|                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
 | |
|                 for (i, sha1) in enumerate(sha1s)
 | |
|                 ]
 | |
| 
 | |
|             if discarded_revisions:
 | |
|                 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
 | |
|                     yield line
 | |
|                 yield '\n'
 | |
|                 for r in discarded_revisions:
 | |
|                     (sha1, subject) = r.rev.get_summary()
 | |
|                     yield r.expand(
 | |
|                         BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,
 | |
|                         )
 | |
|                 for line in self.generate_revision_change_graph(push):
 | |
|                     yield line
 | |
|             else:
 | |
|                 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
 | |
|                     yield line
 | |
| 
 | |
|         elif not self.old.commit_sha1 and not self.new.commit_sha1:
 | |
|             for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
 | |
|                 yield line
 | |
| 
 | |
|     def generate_create_summary(self, push):
 | |
|         """Called for the creation of a reference."""
 | |
| 
 | |
|         # This is a new reference and so oldrev is not valid
 | |
|         (sha1, subject) = self.new.get_summary()
 | |
|         yield self.expand(
 | |
|             BRIEF_SUMMARY_TEMPLATE, action='at',
 | |
|             rev_short=sha1, text=subject,
 | |
|             )
 | |
|         yield '\n'
 | |
| 
 | |
|     def generate_update_summary(self, push):
 | |
|         """Called for the change of a pre-existing branch."""
 | |
| 
 | |
|         return iter([])
 | |
| 
 | |
|     def generate_delete_summary(self, push):
 | |
|         """Called for the deletion of any type of reference."""
 | |
| 
 | |
|         (sha1, subject) = self.old.get_summary()
 | |
|         yield self.expand(
 | |
|             BRIEF_SUMMARY_TEMPLATE, action='was',
 | |
|             rev_short=sha1, text=subject,
 | |
|             )
 | |
|         yield '\n'
 | |
| 
 | |
|     def get_specific_fromaddr(self):
 | |
|         return self.environment.from_refchange
 | |
| 
 | |
| 
 | |
| class BranchChange(ReferenceChange):
 | |
|     refname_type = 'branch'
 | |
| 
 | |
|     def __init__(self, environment, refname, short_refname, old, new, rev):
 | |
|         ReferenceChange.__init__(
 | |
|             self, environment,
 | |
|             refname=refname, short_refname=short_refname,
 | |
|             old=old, new=new, rev=rev,
 | |
|             )
 | |
|         self.recipients = environment.get_refchange_recipients(self)
 | |
|         self._single_revision = None
 | |
| 
 | |
|     def send_single_combined_email(self, known_added_sha1s):
 | |
|         if not self.environment.combine_when_single_commit:
 | |
|             return None
 | |
| 
 | |
|         # In the sadly-all-too-frequent usecase of people pushing only
 | |
|         # one of their commits at a time to a repository, users feel
 | |
|         # the reference change summary emails are noise rather than
 | |
|         # important signal.  This is because, in this particular
 | |
|         # usecase, there is a reference change summary email for each
 | |
|         # new commit, and all these summaries do is point out that
 | |
|         # there is one new commit (which can readily be inferred by
 | |
|         # the existence of the individual revision email that is also
 | |
|         # sent).  In such cases, our users prefer there to be a combined
 | |
|         # reference change summary/new revision email.
 | |
|         #
 | |
|         # So, if the change is an update and it doesn't discard any
 | |
|         # commits, and it adds exactly one non-merge commit (gerrit
 | |
|         # forces a workflow where every commit is individually merged
 | |
|         # and the git-multimail hook fired off for just this one
 | |
|         # change), then we send a combined refchange/revision email.
 | |
|         try:
 | |
|             # If this change is a reference update that doesn't discard
 | |
|             # any commits...
 | |
|             if self.change_type != 'update':
 | |
|                 return None
 | |
| 
 | |
|             if read_git_lines(
 | |
|                     ['merge-base', self.old.sha1, self.new.sha1]
 | |
|                     ) != [self.old.sha1]:
 | |
|                 return None
 | |
| 
 | |
|             # Check if this update introduced exactly one non-merge
 | |
|             # commit:
 | |
| 
 | |
|             def split_line(line):
 | |
|                 """Split line into (sha1, [parent,...])."""
 | |
| 
 | |
|                 words = line.split()
 | |
|                 return (words[0], words[1:])
 | |
| 
 | |
|             # Get the new commits introduced by the push as a list of
 | |
|             # (sha1, [parent,...])
 | |
|             new_commits = [
 | |
|                 split_line(line)
 | |
|                 for line in read_git_lines(
 | |
|                     [
 | |
|                         'log', '-3', '--format=%H %P',
 | |
|                         '%s..%s' % (self.old.sha1, self.new.sha1),
 | |
|                         ]
 | |
|                     )
 | |
|                 ]
 | |
| 
 | |
|             if not new_commits:
 | |
|                 return None
 | |
| 
 | |
|             # If the newest commit is a merge, save it for a later check
 | |
|             # but otherwise ignore it
 | |
|             merge = None
 | |
|             tot = len(new_commits)
 | |
|             if len(new_commits[0][1]) > 1:
 | |
|                 merge = new_commits[0][0]
 | |
|                 del new_commits[0]
 | |
| 
 | |
|             # Our primary check: we can't combine if more than one commit
 | |
|             # is introduced.  We also currently only combine if the new
 | |
|             # commit is a non-merge commit, though it may make sense to
 | |
|             # combine if it is a merge as well.
 | |
|             if not (
 | |
|                     len(new_commits) == 1 and
 | |
|                     len(new_commits[0][1]) == 1 and
 | |
|                     new_commits[0][0] in known_added_sha1s
 | |
|                     ):
 | |
|                 return None
 | |
| 
 | |
|             # We do not want to combine revision and refchange emails if
 | |
|             # those go to separate locations.
 | |
|             rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
 | |
|             if rev.recipients != self.recipients:
 | |
|                 return None
 | |
| 
 | |
|             # We ignored the newest commit if it was just a merge of the one
 | |
|             # commit being introduced.  But we don't want to ignore that
 | |
|             # merge commit it it involved conflict resolutions.  Check that.
 | |
|             if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
 | |
|                 return None
 | |
| 
 | |
|             # We can combine the refchange and one new revision emails
 | |
|             # into one.  Return the Revision that a combined email should
 | |
|             # be sent about.
 | |
|             return rev
 | |
|         except CommandError:
 | |
|             # Cannot determine number of commits in old..new or new..old;
 | |
|             # don't combine reference/revision emails:
 | |
|             return None
 | |
| 
 | |
|     def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
 | |
|         values = revision.get_values()
 | |
|         if extra_header_values:
 | |
|             values.update(extra_header_values)
 | |
|         if 'subject' not in extra_header_values:
 | |
|             values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
 | |
| 
 | |
|         self._single_revision = revision
 | |
|         self._contains_diff()
 | |
|         self.header_template = COMBINED_HEADER_TEMPLATE
 | |
|         self.intro_template = COMBINED_INTRO_TEMPLATE
 | |
|         self.footer_template = COMBINED_FOOTER_TEMPLATE
 | |
| 
 | |
|         def revision_gen_link(base_url):
 | |
|             # revision is used only to generate the body, and
 | |
|             # _content_type is set while generating headers. Get it
 | |
|             # from the BranchChange object.
 | |
|             revision._content_type = self._content_type
 | |
|             return revision.generate_browse_link(base_url)
 | |
|         self.generate_browse_link = revision_gen_link
 | |
|         for line in self.generate_email(push, body_filter, values):
 | |
|             yield line
 | |
| 
 | |
|     def generate_email_body(self, push):
 | |
|         '''Call the appropriate body generation routine.
 | |
| 
 | |
|         If this is a combined refchange/revision email, the special logic
 | |
|         for handling this combined email comes from this function.  For
 | |
|         other cases, we just use the normal handling.'''
 | |
| 
 | |
|         # If self._single_revision isn't set; don't override
 | |
|         if not self._single_revision:
 | |
|             for line in super(BranchChange, self).generate_email_body(push):
 | |
|                 yield line
 | |
|             return
 | |
| 
 | |
|         # This is a combined refchange/revision email; we first provide
 | |
|         # some info from the refchange portion, and then call the revision
 | |
|         # generate_email_body function to handle the revision portion.
 | |
|         adds = list(generate_summaries(
 | |
|             '--topo-order', '--reverse', '%s..%s'
 | |
|             % (self.old.commit_sha1, self.new.commit_sha1,)
 | |
|             ))
 | |
| 
 | |
|         yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
 | |
|         for (sha1, subject) in adds:
 | |
|             yield self.expand(
 | |
|                 BRIEF_SUMMARY_TEMPLATE, action='new',
 | |
|                 rev_short=sha1, text=subject,
 | |
|                 )
 | |
| 
 | |
|         yield self._single_revision.rev.short + " is described below\n"
 | |
|         yield '\n'
 | |
| 
 | |
|         for line in self._single_revision.generate_email_body(push):
 | |
|             yield line
 | |
| 
 | |
| 
 | |
| class AnnotatedTagChange(ReferenceChange):
 | |
|     refname_type = 'annotated tag'
 | |
| 
 | |
|     def __init__(self, environment, refname, short_refname, old, new, rev):
 | |
|         ReferenceChange.__init__(
 | |
|             self, environment,
 | |
|             refname=refname, short_refname=short_refname,
 | |
|             old=old, new=new, rev=rev,
 | |
|             )
 | |
|         self.recipients = environment.get_announce_recipients(self)
 | |
|         self.show_shortlog = environment.announce_show_shortlog
 | |
| 
 | |
|     ANNOTATED_TAG_FORMAT = (
 | |
|         '%(*objectname)\n'
 | |
|         '%(*objecttype)\n'
 | |
|         '%(taggername)\n'
 | |
|         '%(taggerdate)'
 | |
|         )
 | |
| 
 | |
|     def describe_tag(self, push):
 | |
|         """Describe the new value of an annotated tag."""
 | |
| 
 | |
|         # Use git for-each-ref to pull out the individual fields from
 | |
|         # the tag
 | |
|         [tagobject, tagtype, tagger, tagged] = read_git_lines(
 | |
|             ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
 | |
|             )
 | |
| 
 | |
|         yield self.expand(
 | |
|             BRIEF_SUMMARY_TEMPLATE, action='tagging',
 | |
|             rev_short=tagobject, text='(%s)' % (tagtype,),
 | |
|             )
 | |
|         if tagtype == 'commit':
 | |
|             # If the tagged object is a commit, then we assume this is a
 | |
|             # release, and so we calculate which tag this tag is
 | |
|             # replacing
 | |
|             try:
 | |
|                 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
 | |
|             except CommandError:
 | |
|                 prevtag = None
 | |
|             if prevtag:
 | |
|                 yield ' replaces %s\n' % (prevtag,)
 | |
|         else:
 | |
|             prevtag = None
 | |
|             yield '  length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
 | |
| 
 | |
|         yield '      by %s\n' % (tagger,)
 | |
|         yield '      on %s\n' % (tagged,)
 | |
|         yield '\n'
 | |
| 
 | |
|         # Show the content of the tag message; this might contain a
 | |
|         # change log or release notes so is worth displaying.
 | |
|         yield LOGBEGIN
 | |
|         contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
 | |
|         contents = contents[contents.index('\n') + 1:]
 | |
|         if contents and contents[-1][-1:] != '\n':
 | |
|             contents.append('\n')
 | |
|         for line in contents:
 | |
|             yield line
 | |
| 
 | |
|         if self.show_shortlog and tagtype == 'commit':
 | |
|             # Only commit tags make sense to have rev-list operations
 | |
|             # performed on them
 | |
|             yield '\n'
 | |
|             if prevtag:
 | |
|                 # Show changes since the previous release
 | |
|                 revlist = read_git_output(
 | |
|                     ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
 | |
|                     keepends=True,
 | |
|                     )
 | |
|             else:
 | |
|                 # No previous tag, show all the changes since time
 | |
|                 # began
 | |
|                 revlist = read_git_output(
 | |
|                     ['rev-list', '--pretty=short', '%s' % (self.new,)],
 | |
|                     keepends=True,
 | |
|                     )
 | |
|             for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
 | |
|                 yield line
 | |
| 
 | |
|         yield LOGEND
 | |
|         yield '\n'
 | |
| 
 | |
|     def generate_create_summary(self, push):
 | |
|         """Called for the creation of an annotated tag."""
 | |
| 
 | |
|         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
 | |
|             yield line
 | |
| 
 | |
|         for line in self.describe_tag(push):
 | |
|             yield line
 | |
| 
 | |
|     def generate_update_summary(self, push):
 | |
|         """Called for the update of an annotated tag.
 | |
| 
 | |
|         This is probably a rare event and may not even be allowed."""
 | |
| 
 | |
|         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
 | |
|             yield line
 | |
| 
 | |
|         for line in self.describe_tag(push):
 | |
|             yield line
 | |
| 
 | |
|     def generate_delete_summary(self, push):
 | |
|         """Called when a non-annotated reference is updated."""
 | |
| 
 | |
|         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
 | |
|             yield line
 | |
| 
 | |
|         yield self.expand('   tag was  %(oldrev_short)s\n')
 | |
|         yield '\n'
 | |
| 
 | |
| 
 | |
| class NonAnnotatedTagChange(ReferenceChange):
 | |
|     refname_type = 'tag'
 | |
| 
 | |
|     def __init__(self, environment, refname, short_refname, old, new, rev):
 | |
|         ReferenceChange.__init__(
 | |
|             self, environment,
 | |
|             refname=refname, short_refname=short_refname,
 | |
|             old=old, new=new, rev=rev,
 | |
|             )
 | |
|         self.recipients = environment.get_refchange_recipients(self)
 | |
| 
 | |
|     def generate_create_summary(self, push):
 | |
|         """Called for the creation of an annotated tag."""
 | |
| 
 | |
|         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
 | |
|             yield line
 | |
| 
 | |
|     def generate_update_summary(self, push):
 | |
|         """Called when a non-annotated reference is updated."""
 | |
| 
 | |
|         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
 | |
|             yield line
 | |
| 
 | |
|     def generate_delete_summary(self, push):
 | |
|         """Called when a non-annotated reference is updated."""
 | |
| 
 | |
|         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
 | |
|             yield line
 | |
| 
 | |
|         for line in ReferenceChange.generate_delete_summary(self, push):
 | |
|             yield line
 | |
| 
 | |
| 
 | |
| class OtherReferenceChange(ReferenceChange):
 | |
|     refname_type = 'reference'
 | |
| 
 | |
|     def __init__(self, environment, refname, short_refname, old, new, rev):
 | |
|         # We use the full refname as short_refname, because otherwise
 | |
|         # the full name of the reference would not be obvious from the
 | |
|         # text of the email.
 | |
|         ReferenceChange.__init__(
 | |
|             self, environment,
 | |
|             refname=refname, short_refname=refname,
 | |
|             old=old, new=new, rev=rev,
 | |
|             )
 | |
|         self.recipients = environment.get_refchange_recipients(self)
 | |
| 
 | |
| 
 | |
| class Mailer(object):
 | |
|     """An object that can send emails."""
 | |
| 
 | |
|     def __init__(self, environment):
 | |
|         self.environment = environment
 | |
| 
 | |
|     def close(self):
 | |
|         pass
 | |
| 
 | |
|     def send(self, lines, to_addrs):
 | |
|         """Send an email consisting of lines.
 | |
| 
 | |
|         lines must be an iterable over the lines constituting the
 | |
|         header and body of the email.  to_addrs is a list of recipient
 | |
|         addresses (can be needed even if lines already contains a
 | |
|         "To:" field).  It can be either a string (comma-separated list
 | |
|         of email addresses) or a Python list of individual email
 | |
|         addresses.
 | |
| 
 | |
|         """
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
| 
 | |
| class SendMailer(Mailer):
 | |
|     """Send emails using 'sendmail -oi -t'."""
 | |
| 
 | |
|     SENDMAIL_CANDIDATES = [
 | |
|         '/usr/sbin/sendmail',
 | |
|         '/usr/lib/sendmail',
 | |
|         ]
 | |
| 
 | |
|     @staticmethod
 | |
|     def find_sendmail():
 | |
|         for path in SendMailer.SENDMAIL_CANDIDATES:
 | |
|             if os.access(path, os.X_OK):
 | |
|                 return path
 | |
|         else:
 | |
|             raise ConfigurationException(
 | |
|                 'No sendmail executable found.  '
 | |
|                 'Try setting multimailhook.sendmailCommand.'
 | |
|                 )
 | |
| 
 | |
|     def __init__(self, environment, command=None, envelopesender=None):
 | |
|         """Construct a SendMailer instance.
 | |
| 
 | |
|         command should be the command and arguments used to invoke
 | |
|         sendmail, as a list of strings.  If an envelopesender is
 | |
|         provided, it will also be passed to the command, via '-f
 | |
|         envelopesender'."""
 | |
|         super(SendMailer, self).__init__(environment)
 | |
|         if command:
 | |
|             self.command = command[:]
 | |
|         else:
 | |
|             self.command = [self.find_sendmail(), '-oi', '-t']
 | |
| 
 | |
|         if envelopesender:
 | |
|             self.command.extend(['-f', envelopesender])
 | |
| 
 | |
|     def send(self, lines, to_addrs):
 | |
|         try:
 | |
|             p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
 | |
|         except OSError:
 | |
|             self.environment.get_logger().error(
 | |
|                 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
 | |
|                 '*** %s\n' % sys.exc_info()[1] +
 | |
|                 '*** Try setting multimailhook.mailer to "smtp"\n' +
 | |
|                 '*** to send emails without using the sendmail command.\n'
 | |
|                 )
 | |
|             sys.exit(1)
 | |
|         try:
 | |
|             lines = (str_to_bytes(line) for line in lines)
 | |
|             p.stdin.writelines(lines)
 | |
|         except Exception:
 | |
|             self.environment.get_logger().error(
 | |
|                 '*** Error while generating commit email\n'
 | |
|                 '***  - mail sending aborted.\n'
 | |
|                 )
 | |
|             if hasattr(p, 'terminate'):
 | |
|                 # subprocess.terminate() is not available in Python 2.4
 | |
|                 p.terminate()
 | |
|             else:
 | |
|                 import signal
 | |
|                 os.kill(p.pid, signal.SIGTERM)
 | |
|             raise
 | |
|         else:
 | |
|             p.stdin.close()
 | |
|             retcode = p.wait()
 | |
|             if retcode:
 | |
|                 raise CommandError(self.command, retcode)
 | |
| 
 | |
| 
 | |
| class SMTPMailer(Mailer):
 | |
|     """Send emails using Python's smtplib."""
 | |
| 
 | |
|     def __init__(self, environment,
 | |
|                  envelopesender, smtpserver,
 | |
|                  smtpservertimeout=10.0, smtpserverdebuglevel=0,
 | |
|                  smtpencryption='none',
 | |
|                  smtpuser='', smtppass='',
 | |
|                  smtpcacerts=''
 | |
|                  ):
 | |
|         super(SMTPMailer, self).__init__(environment)
 | |
|         if not envelopesender:
 | |
|             self.environment.get_logger().error(
 | |
|                 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
 | |
|                 'please set either multimailhook.envelopeSender or user.email\n'
 | |
|                 )
 | |
|             sys.exit(1)
 | |
|         if smtpencryption == 'ssl' and not (smtpuser and smtppass):
 | |
|             raise ConfigurationException(
 | |
|                 'Cannot use SMTPMailer with security option ssl '
 | |
|                 'without options username and password.'
 | |
|                 )
 | |
|         self.envelopesender = envelopesender
 | |
|         self.smtpserver = smtpserver
 | |
|         self.smtpservertimeout = smtpservertimeout
 | |
|         self.smtpserverdebuglevel = smtpserverdebuglevel
 | |
|         self.security = smtpencryption
 | |
|         self.username = smtpuser
 | |
|         self.password = smtppass
 | |
|         self.smtpcacerts = smtpcacerts
 | |
|         self.loggedin = False
 | |
|         try:
 | |
|             def call(klass, server, timeout):
 | |
|                 try:
 | |
|                     return klass(server, timeout=timeout)
 | |
|                 except TypeError:
 | |
|                     # Old Python versions do not have timeout= argument.
 | |
|                     return klass(server)
 | |
|             if self.security == 'none':
 | |
|                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
 | |
|             elif self.security == 'ssl':
 | |
|                 if self.smtpcacerts:
 | |
|                     raise smtplib.SMTPException(
 | |
|                         "Checking certificate is not supported for ssl, prefer starttls"
 | |
|                         )
 | |
|                 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
 | |
|             elif self.security == 'tls':
 | |
|                 if 'ssl' not in sys.modules:
 | |
|                     self.environment.get_logger().error(
 | |
|                         '*** Your Python version does not have the ssl library installed\n'
 | |
|                         '*** smtpEncryption=tls is not available.\n'
 | |
|                         '*** Either upgrade Python to 2.6 or later\n'
 | |
|                         '    or use git_multimail.py version 1.2.\n')
 | |
|                 if ':' not in self.smtpserver:
 | |
|                     self.smtpserver += ':587'  # default port for TLS
 | |
|                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
 | |
|                 # start: ehlo + starttls
 | |
|                 # equivalent to
 | |
|                 #     self.smtp.ehlo()
 | |
|                 #     self.smtp.starttls()
 | |
|                 # with acces to the ssl layer
 | |
|                 self.smtp.ehlo()
 | |
|                 if not self.smtp.has_extn("starttls"):
 | |
|                     raise smtplib.SMTPException("STARTTLS extension not supported by server")
 | |
|                 resp, reply = self.smtp.docmd("STARTTLS")
 | |
|                 if resp != 220:
 | |
|                     raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
 | |
|                 if self.smtpcacerts:
 | |
|                     self.smtp.sock = ssl.wrap_socket(
 | |
|                         self.smtp.sock,
 | |
|                         ca_certs=self.smtpcacerts,
 | |
|                         cert_reqs=ssl.CERT_REQUIRED
 | |
|                         )
 | |
|                 else:
 | |
|                     self.smtp.sock = ssl.wrap_socket(
 | |
|                         self.smtp.sock,
 | |
|                         cert_reqs=ssl.CERT_NONE
 | |
|                         )
 | |
|                     self.environment.get_logger().error(
 | |
|                         '*** Warning, the server certificat is not verified (smtp) ***\n'
 | |
|                         '***          set the option smtpCACerts                   ***\n'
 | |
|                         )
 | |
|                 if not hasattr(self.smtp.sock, "read"):
 | |
|                     # using httplib.FakeSocket with Python 2.5.x or earlier
 | |
|                     self.smtp.sock.read = self.smtp.sock.recv
 | |
|                 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
 | |
|                 self.smtp.helo_resp = None
 | |
|                 self.smtp.ehlo_resp = None
 | |
|                 self.smtp.esmtp_features = {}
 | |
|                 self.smtp.does_esmtp = 0
 | |
|                 # end:   ehlo + starttls
 | |
|                 self.smtp.ehlo()
 | |
|             else:
 | |
|                 sys.stdout.write('*** Error: Control reached an invalid option. ***')
 | |
|                 sys.exit(1)
 | |
|             if self.smtpserverdebuglevel > 0:
 | |
|                 sys.stdout.write(
 | |
|                     "*** Setting debug on for SMTP server connection (%s) ***\n"
 | |
|                     % self.smtpserverdebuglevel)
 | |
|                 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
 | |
|         except Exception:
 | |
|             self.environment.get_logger().error(
 | |
|                 '*** Error establishing SMTP connection to %s ***\n'
 | |
|                 '*** %s\n'
 | |
|                 % (self.smtpserver, sys.exc_info()[1]))
 | |
|             sys.exit(1)
 | |
| 
 | |
|     def close(self):
 | |
|         if hasattr(self, 'smtp'):
 | |
|             self.smtp.quit()
 | |
|             del self.smtp
 | |
| 
 | |
|     def __del__(self):
 | |
|         self.close()
 | |
| 
 | |
|     def send(self, lines, to_addrs):
 | |
|         try:
 | |
|             if self.username or self.password:
 | |
|                 if not self.loggedin:
 | |
|                     self.smtp.login(self.username, self.password)
 | |
|                     self.loggedin = True
 | |
|             msg = ''.join(lines)
 | |
|             # turn comma-separated list into Python list if needed.
 | |
|             if is_string(to_addrs):
 | |
|                 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
 | |
|             self.smtp.sendmail(self.envelopesender, to_addrs, msg)
 | |
|         except socket.timeout:
 | |
|             self.environment.get_logger().error(
 | |
|                 '*** Error sending email ***\n'
 | |
|                 '*** SMTP server timed out (timeout is %s)\n'
 | |
|                 % self.smtpservertimeout)
 | |
|         except smtplib.SMTPResponseException:
 | |
|             err = sys.exc_info()[1]
 | |
|             self.environment.get_logger().error(
 | |
|                 '*** Error sending email ***\n'
 | |
|                 '*** Error %d: %s\n'
 | |
|                 % (err.smtp_code, bytes_to_str(err.smtp_error)))
 | |
|             try:
 | |
|                 smtp = self.smtp
 | |
|                 # delete the field before quit() so that in case of
 | |
|                 # error, self.smtp is deleted anyway.
 | |
|                 del self.smtp
 | |
|                 smtp.quit()
 | |
|             except:
 | |
|                 self.environment.get_logger().error(
 | |
|                     '*** Error closing the SMTP connection ***\n'
 | |
|                     '*** Exiting anyway ... ***\n'
 | |
|                     '*** %s\n' % sys.exc_info()[1])
 | |
|             sys.exit(1)
 | |
| 
 | |
| 
 | |
| class OutputMailer(Mailer):
 | |
|     """Write emails to an output stream, bracketed by lines of '=' characters.
 | |
| 
 | |
|     This is intended for debugging purposes."""
 | |
| 
 | |
|     SEPARATOR = '=' * 75 + '\n'
 | |
| 
 | |
|     def __init__(self, f, environment=None):
 | |
|         super(OutputMailer, self).__init__(environment=environment)
 | |
|         self.f = f
 | |
| 
 | |
|     def send(self, lines, to_addrs):
 | |
|         write_str(self.f, self.SEPARATOR)
 | |
|         for line in lines:
 | |
|             write_str(self.f, line)
 | |
|         write_str(self.f, self.SEPARATOR)
 | |
| 
 | |
| 
 | |
| def get_git_dir():
 | |
|     """Determine GIT_DIR.
 | |
| 
 | |
|     Determine GIT_DIR either from the GIT_DIR environment variable or
 | |
|     from the working directory, using Git's usual rules."""
 | |
| 
 | |
|     try:
 | |
|         return read_git_output(['rev-parse', '--git-dir'])
 | |
|     except CommandError:
 | |
|         sys.stderr.write('fatal: git_multimail: not in a git directory\n')
 | |
|         sys.exit(1)
 | |
| 
 | |
| 
 | |
| class Environment(object):
 | |
|     """Describes the environment in which the push is occurring.
 | |
| 
 | |
|     An Environment object encapsulates information about the local
 | |
|     environment.  For example, it knows how to determine:
 | |
| 
 | |
|     * the name of the repository to which the push occurred
 | |
| 
 | |
|     * what user did the push
 | |
| 
 | |
|     * what users want to be informed about various types of changes.
 | |
| 
 | |
|     An Environment object is expected to have the following methods:
 | |
| 
 | |
|         get_repo_shortname()
 | |
| 
 | |
|             Return a short name for the repository, for display
 | |
|             purposes.
 | |
| 
 | |
|         get_repo_path()
 | |
| 
 | |
|             Return the absolute path to the Git repository.
 | |
| 
 | |
|         get_emailprefix()
 | |
| 
 | |
|             Return a string that will be prefixed to every email's
 | |
|             subject.
 | |
| 
 | |
|         get_pusher()
 | |
| 
 | |
|             Return the username of the person who pushed the changes.
 | |
|             This value is used in the email body to indicate who
 | |
|             pushed the change.
 | |
| 
 | |
|         get_pusher_email() (may return None)
 | |
| 
 | |
|             Return the email address of the person who pushed the
 | |
|             changes.  The value should be a single RFC 2822 email
 | |
|             address as a string; e.g., "Joe User <user@example.com>"
 | |
|             if available, otherwise "user@example.com".  If set, the
 | |
|             value is used as the Reply-To address for refchange
 | |
|             emails.  If it is impossible to determine the pusher's
 | |
|             email, this attribute should be set to None (in which case
 | |
|             no Reply-To header will be output).
 | |
| 
 | |
|         get_sender()
 | |
| 
 | |
|             Return the address to be used as the 'From' email address
 | |
|             in the email envelope.
 | |
| 
 | |
|         get_fromaddr(change=None)
 | |
| 
 | |
|             Return the 'From' email address used in the email 'From:'
 | |
|             headers.  If the change is known when this function is
 | |
|             called, it is passed in as the 'change' parameter.  (May
 | |
|             be a full RFC 2822 email address like 'Joe User
 | |
|             <user@example.com>'.)
 | |
| 
 | |
|         get_administrator()
 | |
| 
 | |
|             Return the name and/or email of the repository
 | |
|             administrator.  This value is used in the footer as the
 | |
|             person to whom requests to be removed from the
 | |
|             notification list should be sent.  Ideally, it should
 | |
|             include a valid email address.
 | |
| 
 | |
|         get_reply_to_refchange()
 | |
|         get_reply_to_commit()
 | |
| 
 | |
|             Return the address to use in the email "Reply-To" header,
 | |
|             as a string.  These can be an RFC 2822 email address, or
 | |
|             None to omit the "Reply-To" header.
 | |
|             get_reply_to_refchange() is used for refchange emails;
 | |
|             get_reply_to_commit() is used for individual commit
 | |
|             emails.
 | |
| 
 | |
|         get_ref_filter_regex()
 | |
| 
 | |
|             Return a tuple -- a compiled regex, and a boolean indicating
 | |
|             whether the regex picks refs to include (if False, the regex
 | |
|             matches on refs to exclude).
 | |
| 
 | |
|         get_default_ref_ignore_regex()
 | |
| 
 | |
|             Return a regex that should be ignored for both what emails
 | |
|             to send and when computing what commits are considered new
 | |
|             to the repository.  Default is "^refs/notes/".
 | |
| 
 | |
|         get_max_subject_length()
 | |
| 
 | |
|             Return an int giving the maximal length for the subject
 | |
|             (git log --oneline).
 | |
| 
 | |
|     They should also define the following attributes:
 | |
| 
 | |
|         announce_show_shortlog (bool)
 | |
| 
 | |
|             True iff announce emails should include a shortlog.
 | |
| 
 | |
|         commit_email_format (string)
 | |
| 
 | |
|             If "html", generate commit emails in HTML instead of plain text
 | |
|             used by default.
 | |
| 
 | |
|         html_in_intro (bool)
 | |
|         html_in_footer (bool)
 | |
| 
 | |
|             When generating HTML emails, the introduction (respectively,
 | |
|             the footer) will be HTML-escaped iff html_in_intro (respectively,
 | |
|             the footer) is true. When false, only the values used to expand
 | |
|             the template are escaped.
 | |
| 
 | |
|         refchange_showgraph (bool)
 | |
| 
 | |
|             True iff refchanges emails should include a detailed graph.
 | |
| 
 | |
|         refchange_showlog (bool)
 | |
| 
 | |
|             True iff refchanges emails should include a detailed log.
 | |
| 
 | |
|         diffopts (list of strings)
 | |
| 
 | |
|             The options that should be passed to 'git diff' for the
 | |
|             summary email.  The value should be a list of strings
 | |
|             representing words to be passed to the command.
 | |
| 
 | |
|         graphopts (list of strings)
 | |
| 
 | |
|             Analogous to diffopts, but contains options passed to
 | |
|             'git log --graph' when generating the detailed graph for
 | |
|             a set of commits (see refchange_showgraph)
 | |
| 
 | |
|         logopts (list of strings)
 | |
| 
 | |
|             Analogous to diffopts, but contains options passed to
 | |
|             'git log' when generating the detailed log for a set of
 | |
|             commits (see refchange_showlog)
 | |
| 
 | |
|         commitlogopts (list of strings)
 | |
| 
 | |
|             The options that should be passed to 'git log' for each
 | |
|             commit mail.  The value should be a list of strings
 | |
|             representing words to be passed to the command.
 | |
| 
 | |
|         date_substitute (string)
 | |
| 
 | |
|             String to be used in substitution for 'Date:' at start of
 | |
|             line in the output of 'git log'.
 | |
| 
 | |
|         quiet (bool)
 | |
|             On success do not write to stderr
 | |
| 
 | |
|         stdout (bool)
 | |
|             Write email to stdout rather than emailing. Useful for debugging
 | |
| 
 | |
|         combine_when_single_commit (bool)
 | |
| 
 | |
|             True if a combined email should be produced when a single
 | |
|             new commit is pushed to a branch, False otherwise.
 | |
| 
 | |
|         from_refchange, from_commit (strings)
 | |
| 
 | |
|             Addresses to use for the From: field for refchange emails
 | |
|             and commit emails respectively.  Set from
 | |
|             multimailhook.fromRefchange and multimailhook.fromCommit
 | |
|             by ConfigEnvironmentMixin.
 | |
| 
 | |
|         log_file, error_log_file, debug_log_file (string)
 | |
| 
 | |
|             Name of a file to which logs should be sent.
 | |
| 
 | |
|         verbose (int)
 | |
| 
 | |
|             How verbose the system should be.
 | |
|             - 0 (default): show info, errors, ...
 | |
|             - 1 : show basic debug info
 | |
|     """
 | |
| 
 | |
|     REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
 | |
| 
 | |
|     def __init__(self, osenv=None):
 | |
|         self.osenv = osenv or os.environ
 | |
|         self.announce_show_shortlog = False
 | |
|         self.commit_email_format = "text"
 | |
|         self.html_in_intro = False
 | |
|         self.html_in_footer = False
 | |
|         self.commitBrowseURL = None
 | |
|         self.maxcommitemails = 500
 | |
|         self.excludemergerevisions = False
 | |
|         self.diffopts = ['--stat', '--summary', '--find-copies-harder']
 | |
|         self.graphopts = ['--oneline', '--decorate']
 | |
|         self.logopts = []
 | |
|         self.refchange_showgraph = False
 | |
|         self.refchange_showlog = False
 | |
|         self.commitlogopts = ['-C', '--stat', '-p', '--cc']
 | |
|         self.date_substitute = 'AuthorDate: '
 | |
|         self.quiet = False
 | |
|         self.stdout = False
 | |
|         self.combine_when_single_commit = True
 | |
|         self.logger = None
 | |
| 
 | |
|         self.COMPUTED_KEYS = [
 | |
|             'administrator',
 | |
|             'charset',
 | |
|             'emailprefix',
 | |
|             'pusher',
 | |
|             'pusher_email',
 | |
|             'repo_path',
 | |
|             'repo_shortname',
 | |
|             'sender',
 | |
|             ]
 | |
| 
 | |
|         self._values = None
 | |
| 
 | |
|     def get_logger(self):
 | |
|         """Get (possibly creates) the logger associated to this environment."""
 | |
|         if self.logger is None:
 | |
|             self.logger = Logger(self)
 | |
|         return self.logger
 | |
| 
 | |
|     def get_repo_shortname(self):
 | |
|         """Use the last part of the repo path, with ".git" stripped off if present."""
 | |
| 
 | |
|         basename = os.path.basename(os.path.abspath(self.get_repo_path()))
 | |
|         m = self.REPO_NAME_RE.match(basename)
 | |
|         if m:
 | |
|             return m.group('name')
 | |
|         else:
 | |
|             return basename
 | |
| 
 | |
|     def get_pusher(self):
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def get_pusher_email(self):
 | |
|         return None
 | |
| 
 | |
|     def get_fromaddr(self, change=None):
 | |
|         config = Config('user')
 | |
|         fromname = config.get('name', default='')
 | |
|         fromemail = config.get('email', default='')
 | |
|         if fromemail:
 | |
|             return formataddr([fromname, fromemail])
 | |
|         return self.get_sender()
 | |
| 
 | |
|     def get_administrator(self):
 | |
|         return 'the administrator of this repository'
 | |
| 
 | |
|     def get_emailprefix(self):
 | |
|         return ''
 | |
| 
 | |
|     def get_repo_path(self):
 | |
|         if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
 | |
|             path = get_git_dir()
 | |
|         else:
 | |
|             path = read_git_output(['rev-parse', '--show-toplevel'])
 | |
|         return os.path.abspath(path)
 | |
| 
 | |
|     def get_charset(self):
 | |
|         return CHARSET
 | |
| 
 | |
|     def get_values(self):
 | |
|         """Return a dictionary {keyword: expansion} for this Environment.
 | |
| 
 | |
|         This method is called by Change._compute_values().  The keys
 | |
|         in the returned dictionary are available to be used in any of
 | |
|         the templates.  The dictionary is created by calling
 | |
|         self.get_NAME() for each of the attributes named in
 | |
|         COMPUTED_KEYS and recording those that do not return None.
 | |
|         The return value is always a new dictionary."""
 | |
| 
 | |
|         if self._values is None:
 | |
|             values = {'': ''}  # %()s expands to the empty string.
 | |
| 
 | |
|             for key in self.COMPUTED_KEYS:
 | |
|                 value = getattr(self, 'get_%s' % (key,))()
 | |
|                 if value is not None:
 | |
|                     values[key] = value
 | |
| 
 | |
|             self._values = values
 | |
| 
 | |
|         return self._values.copy()
 | |
| 
 | |
|     def get_refchange_recipients(self, refchange):
 | |
|         """Return the recipients for notifications about refchange.
 | |
| 
 | |
|         Return the list of email addresses to which notifications
 | |
|         about the specified ReferenceChange should be sent."""
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def get_announce_recipients(self, annotated_tag_change):
 | |
|         """Return the recipients for notifications about annotated_tag_change.
 | |
| 
 | |
|         Return the list of email addresses to which notifications
 | |
|         about the specified AnnotatedTagChange should be sent."""
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def get_reply_to_refchange(self, refchange):
 | |
|         return self.get_pusher_email()
 | |
| 
 | |
|     def get_revision_recipients(self, revision):
 | |
|         """Return the recipients for messages about revision.
 | |
| 
 | |
|         Return the list of email addresses to which notifications
 | |
|         about the specified Revision should be sent.  This method
 | |
|         could be overridden, for example, to take into account the
 | |
|         contents of the revision when deciding whom to notify about
 | |
|         it.  For example, there could be a scheme for users to express
 | |
|         interest in particular files or subdirectories, and only
 | |
|         receive notification emails for revisions that affecting those
 | |
|         files."""
 | |
| 
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def get_reply_to_commit(self, revision):
 | |
|         return revision.author
 | |
| 
 | |
|     def get_default_ref_ignore_regex(self):
 | |
|         # The commit messages of git notes are essentially meaningless
 | |
|         # and "filenames" in git notes commits are an implementational
 | |
|         # detail that might surprise users at first.  As such, we
 | |
|         # would need a completely different method for handling emails
 | |
|         # of git notes in order for them to be of benefit for users,
 | |
|         # which we simply do not have right now.
 | |
|         return "^refs/notes/"
 | |
| 
 | |
|     def get_max_subject_length(self):
 | |
|         """Return the maximal subject line (git log --oneline) length.
 | |
|         Longer subject lines will be truncated."""
 | |
|         raise NotImplementedError()
 | |
| 
 | |
|     def filter_body(self, lines):
 | |
|         """Filter the lines intended for an email body.
 | |
| 
 | |
|         lines is an iterable over the lines that would go into the
 | |
|         email body.  Filter it (e.g., limit the number of lines, the
 | |
|         line length, character set, etc.), returning another iterable.
 | |
|         See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
 | |
|         for classes implementing this functionality."""
 | |
| 
 | |
|         return lines
 | |
| 
 | |
|     def log_msg(self, msg):
 | |
|         """Write the string msg on a log file or on stderr.
 | |
| 
 | |
|         Sends the text to stderr by default, override to change the behavior."""
 | |
|         self.get_logger().info(msg)
 | |
| 
 | |
|     def log_warning(self, msg):
 | |
|         """Write the string msg on a log file or on stderr.
 | |
| 
 | |
|         Sends the text to stderr by default, override to change the behavior."""
 | |
|         self.get_logger().warning(msg)
 | |
| 
 | |
|     def log_error(self, msg):
 | |
|         """Write the string msg on a log file or on stderr.
 | |
| 
 | |
|         Sends the text to stderr by default, override to change the behavior."""
 | |
|         self.get_logger().error(msg)
 | |
| 
 | |
|     def check(self):
 | |
|         pass
 | |
| 
 | |
| 
 | |
| class ConfigEnvironmentMixin(Environment):
 | |
|     """A mixin that sets self.config to its constructor's config argument.
 | |
| 
 | |
|     This class's constructor consumes the "config" argument.
 | |
| 
 | |
|     Mixins that need to inspect the config should inherit from this
 | |
|     class (1) to make sure that "config" is still in the constructor
 | |
|     arguments with its own constructor runs and/or (2) to be sure that
 | |
|     self.config is set after construction."""
 | |
| 
 | |
|     def __init__(self, config, **kw):
 | |
|         super(ConfigEnvironmentMixin, self).__init__(**kw)
 | |
|         self.config = config
 | |
| 
 | |
| 
 | |
| class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
 | |
|     """An Environment that reads most of its information from "git config"."""
 | |
| 
 | |
|     @staticmethod
 | |
|     def forbid_field_values(name, value, forbidden):
 | |
|         for forbidden_val in forbidden:
 | |
|             if value is not None and value.lower() == forbidden:
 | |
|                 raise ConfigurationException(
 | |
|                     '"%s" is not an allowed setting for %s' % (value, name)
 | |
|                     )
 | |
| 
 | |
|     def __init__(self, config, **kw):
 | |
|         super(ConfigOptionsEnvironmentMixin, self).__init__(
 | |
|             config=config, **kw
 | |
|             )
 | |
| 
 | |
|         for var, cfg in (
 | |
|                 ('announce_show_shortlog', 'announceshortlog'),
 | |
|                 ('refchange_showgraph', 'refchangeShowGraph'),
 | |
|                 ('refchange_showlog', 'refchangeshowlog'),
 | |
|                 ('quiet', 'quiet'),
 | |
|                 ('stdout', 'stdout'),
 | |
|                 ):
 | |
|             val = config.get_bool(cfg)
 | |
|             if val is not None:
 | |
|                 setattr(self, var, val)
 | |
| 
 | |
|         commit_email_format = config.get('commitEmailFormat')
 | |
|         if commit_email_format is not None:
 | |
|             if commit_email_format != "html" and commit_email_format != "text":
 | |
|                 self.log_warning(
 | |
|                     '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
 | |
|                     commit_email_format +
 | |
|                     '*** Expected either "text" or "html".  Ignoring.\n'
 | |
|                     )
 | |
|             else:
 | |
|                 self.commit_email_format = commit_email_format
 | |
| 
 | |
|         html_in_intro = config.get_bool('htmlInIntro')
 | |
|         if html_in_intro is not None:
 | |
|             self.html_in_intro = html_in_intro
 | |
| 
 | |
|         html_in_footer = config.get_bool('htmlInFooter')
 | |
|         if html_in_footer is not None:
 | |
|             self.html_in_footer = html_in_footer
 | |
| 
 | |
|         self.commitBrowseURL = config.get('commitBrowseURL')
 | |
| 
 | |
|         self.excludemergerevisions = config.get('excludeMergeRevisions')
 | |
| 
 | |
|         maxcommitemails = config.get('maxcommitemails')
 | |
|         if maxcommitemails is not None:
 | |
|             try:
 | |
|                 self.maxcommitemails = int(maxcommitemails)
 | |
|             except ValueError:
 | |
|                 self.log_warning(
 | |
|                     '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
 | |
|                     % maxcommitemails +
 | |
|                     '*** Expected a number.  Ignoring.\n'
 | |
|                     )
 | |
| 
 | |
|         diffopts = config.get('diffopts')
 | |
|         if diffopts is not None:
 | |
|             self.diffopts = shlex.split(diffopts)
 | |
| 
 | |
|         graphopts = config.get('graphOpts')
 | |
|         if graphopts is not None:
 | |
|             self.graphopts = shlex.split(graphopts)
 | |
| 
 | |
|         logopts = config.get('logopts')
 | |
|         if logopts is not None:
 | |
|             self.logopts = shlex.split(logopts)
 | |
| 
 | |
|         commitlogopts = config.get('commitlogopts')
 | |
|         if commitlogopts is not None:
 | |
|             self.commitlogopts = shlex.split(commitlogopts)
 | |
| 
 | |
|         date_substitute = config.get('dateSubstitute')
 | |
|         if date_substitute == 'none':
 | |
|             self.date_substitute = None
 | |
|         elif date_substitute is not None:
 | |
|             self.date_substitute = date_substitute
 | |
| 
 | |
|         reply_to = config.get('replyTo')
 | |
|         self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
 | |
|         self.forbid_field_values('replyToRefchange',
 | |
|                                  self.__reply_to_refchange,
 | |
|                                  ['author'])
 | |
|         self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
 | |
| 
 | |
|         self.from_refchange = config.get('fromRefchange')
 | |
|         self.forbid_field_values('fromRefchange',
 | |
|                                  self.from_refchange,
 | |
|                                  ['author', 'none'])
 | |
|         self.from_commit = config.get('fromCommit')
 | |
|         self.forbid_field_values('fromCommit',
 | |
|                                  self.from_commit,
 | |
|                                  ['none'])
 | |
| 
 | |
|         combine = config.get_bool('combineWhenSingleCommit')
 | |
|         if combine is not None:
 | |
|             self.combine_when_single_commit = combine
 | |
| 
 | |
|         self.log_file = config.get('logFile', default=None)
 | |
|         self.error_log_file = config.get('errorLogFile', default=None)
 | |
|         self.debug_log_file = config.get('debugLogFile', default=None)
 | |
|         if config.get_bool('Verbose', default=False):
 | |
|             self.verbose = 1
 | |
|         else:
 | |
|             self.verbose = 0
 | |
| 
 | |
|     def get_administrator(self):
 | |
|         return (
 | |
|             self.config.get('administrator') or
 | |
|             self.get_sender() or
 | |
|             super(ConfigOptionsEnvironmentMixin, self).get_administrator()
 | |
|             )
 | |
| 
 | |
|     def get_repo_shortname(self):
 | |
|         return (
 | |
|             self.config.get('reponame') or
 | |
|             super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
 | |
|             )
 | |
| 
 | |
|     def get_emailprefix(self):
 | |
|         emailprefix = self.config.get('emailprefix')
 | |
|         if emailprefix is not None:
 | |
|             emailprefix = emailprefix.strip()
 | |
|             if emailprefix:
 | |
|                 emailprefix += ' '
 | |
|         else:
 | |
|             emailprefix = '[%(repo_shortname)s] '
 | |
|         short_name = self.get_repo_shortname()
 | |
|         try:
 | |
|             return emailprefix % {'repo_shortname': short_name}
 | |
|         except:
 | |
|             self.get_logger().error(
 | |
|                 '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix +
 | |
|                 '*** %s\n' % sys.exc_info()[1] +
 | |
|                 "*** Only the '%(repo_shortname)s' placeholder is allowed\n"
 | |
|                 )
 | |
|             raise ConfigurationException(
 | |
|                 '"%s" is not an allowed setting for emailPrefix' % emailprefix
 | |
|                 )
 | |
| 
 | |
|     def get_sender(self):
 | |
|         return self.config.get('envelopesender')
 | |
| 
 | |
|     def process_addr(self, addr, change):
 | |
|         if addr.lower() == 'author':
 | |
|             if hasattr(change, 'author'):
 | |
|                 return change.author
 | |
|             else:
 | |
|                 return None
 | |
|         elif addr.lower() == 'pusher':
 | |
|             return self.get_pusher_email()
 | |
|         elif addr.lower() == 'none':
 | |
|             return None
 | |
|         else:
 | |
|             return addr
 | |
| 
 | |
|     def get_fromaddr(self, change=None):
 | |
|         fromaddr = self.config.get('from')
 | |
|         if change:
 | |
|             specific_fromaddr = change.get_specific_fromaddr()
 | |
|             if specific_fromaddr:
 | |
|                 fromaddr = specific_fromaddr
 | |
|         if fromaddr:
 | |
|             fromaddr = self.process_addr(fromaddr, change)
 | |
|         if fromaddr:
 | |
|             return fromaddr
 | |
|         return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
 | |
| 
 | |
|     def get_reply_to_refchange(self, refchange):
 | |
|         if self.__reply_to_refchange is None:
 | |
|             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
 | |
|         else:
 | |
|             return self.process_addr(self.__reply_to_refchange, refchange)
 | |
| 
 | |
|     def get_reply_to_commit(self, revision):
 | |
|         if self.__reply_to_commit is None:
 | |
|             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
 | |
|         else:
 | |
|             return self.process_addr(self.__reply_to_commit, revision)
 | |
| 
 | |
|     def get_scancommitforcc(self):
 | |
|         return self.config.get('scancommitforcc')
 | |
| 
 | |
| 
 | |
| class FilterLinesEnvironmentMixin(Environment):
 | |
|     """Handle encoding and maximum line length of body lines.
 | |
| 
 | |
|         email_max_line_length (int or None)
 | |
| 
 | |
|             The maximum length of any single line in the email body.
 | |
|             Longer lines are truncated at that length with ' [...]'
 | |
|             appended.
 | |
| 
 | |
|         strict_utf8 (bool)
 | |
| 
 | |
|             If this field is set to True, then the email body text is
 | |
|             expected to be UTF-8.  Any invalid characters are
 | |
|             converted to U+FFFD, the Unicode replacement character
 | |
|             (encoded as UTF-8, of course).
 | |
| 
 | |
|     """
 | |
| 
 | |
|     def __init__(self, strict_utf8=True,
 | |
|                  email_max_line_length=500, max_subject_length=500,
 | |
|                  **kw):
 | |
|         super(FilterLinesEnvironmentMixin, self).__init__(**kw)
 | |
|         self.__strict_utf8 = strict_utf8
 | |
|         self.__email_max_line_length = email_max_line_length
 | |
|         self.__max_subject_length = max_subject_length
 | |
| 
 | |
|     def filter_body(self, lines):
 | |
|         lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
 | |
|         if self.__strict_utf8:
 | |
|             if not PYTHON3:
 | |
|                 lines = (line.decode(ENCODING, 'replace') for line in lines)
 | |
|             # Limit the line length in Unicode-space to avoid
 | |
|             # splitting characters:
 | |
|             if self.__email_max_line_length > 0:
 | |
|                 lines = limit_linelength(lines, self.__email_max_line_length)
 | |
|             if not PYTHON3:
 | |
|                 lines = (line.encode(ENCODING, 'replace') for line in lines)
 | |
|         elif self.__email_max_line_length:
 | |
|             lines = limit_linelength(lines, self.__email_max_line_length)
 | |
| 
 | |
|         return lines
 | |
| 
 | |
|     def get_max_subject_length(self):
 | |
|         return self.__max_subject_length
 | |
| 
 | |
| 
 | |
| class ConfigFilterLinesEnvironmentMixin(
 | |
|         ConfigEnvironmentMixin,
 | |
|         FilterLinesEnvironmentMixin,
 | |
|         ):
 | |
|     """Handle encoding and maximum line length based on config."""
 | |
| 
 | |
|     def __init__(self, config, **kw):
 | |
|         strict_utf8 = config.get_bool('emailstrictutf8', default=None)
 | |
|         if strict_utf8 is not None:
 | |
|             kw['strict_utf8'] = strict_utf8
 | |
| 
 | |
|         email_max_line_length = config.get('emailmaxlinelength')
 | |
|         if email_max_line_length is not None:
 | |
|             kw['email_max_line_length'] = int(email_max_line_length)
 | |
| 
 | |
|         max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)
 | |
|         if max_subject_length is not None:
 | |
|             kw['max_subject_length'] = int(max_subject_length)
 | |
| 
 | |
|         super(ConfigFilterLinesEnvironmentMixin, self).__init__(
 | |
|             config=config, **kw
 | |
|             )
 | |
| 
 | |
| 
 | |
| class MaxlinesEnvironmentMixin(Environment):
 | |
|     """Limit the email body to a specified number of lines."""
 | |
| 
 | |
|     def __init__(self, emailmaxlines, **kw):
 | |
|         super(MaxlinesEnvironmentMixin, self).__init__(**kw)
 | |
|         self.__emailmaxlines = emailmaxlines
 | |
| 
 | |
|     def filter_body(self, lines):
 | |
|         lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
 | |
|         if self.__emailmaxlines > 0:
 | |
|             lines = limit_lines(lines, self.__emailmaxlines)
 | |
|         return lines
 | |
| 
 | |
| 
 | |
| class ConfigMaxlinesEnvironmentMixin(
 | |
|         ConfigEnvironmentMixin,
 | |
|         MaxlinesEnvironmentMixin,
 | |
|         ):
 | |
|     """Limit the email body to the number of lines specified in config."""
 | |
| 
 | |
|     def __init__(self, config, **kw):
 | |
|         emailmaxlines = int(config.get('emailmaxlines', default='0'))
 | |
|         super(ConfigMaxlinesEnvironmentMixin, self).__init__(
 | |
|             config=config,
 | |
|             emailmaxlines=emailmaxlines,
 | |
|             **kw
 | |
|             )
 | |
| 
 | |
| 
 | |
| class FQDNEnvironmentMixin(Environment):
 | |
|     """A mixin that sets the host's FQDN to its constructor argument."""
 | |
| 
 | |
|     def __init__(self, fqdn, **kw):
 | |
|         super(FQDNEnvironmentMixin, self).__init__(**kw)
 | |
|         self.COMPUTED_KEYS += ['fqdn']
 | |
|         self.__fqdn = fqdn
 | |
| 
 | |
|     def get_fqdn(self):
 | |
|         """Return the fully-qualified domain name for this host.
 | |
| 
 | |
|         Return None if it is unavailable or unwanted."""
 | |
| 
 | |
|         return self.__fqdn
 | |
| 
 | |
| 
 | |
| class ConfigFQDNEnvironmentMixin(
 | |
|         ConfigEnvironmentMixin,
 | |
|         FQDNEnvironmentMixin,
 | |
|         ):
 | |
|     """Read the FQDN from the config."""
 | |
| 
 | |
|     def __init__(self, config, **kw):
 | |
|         fqdn = config.get('fqdn')
 | |
|         super(ConfigFQDNEnvironmentMixin, self).__init__(
 | |
|             config=config,
 | |
|             fqdn=fqdn,
 | |
|             **kw
 | |
|             )
 | |
| 
 | |
| 
 | |
| class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
 | |
|     """Get the FQDN by calling socket.getfqdn()."""
 | |
| 
 | |
|     def __init__(self, **kw):
 | |
|         super(ComputeFQDNEnvironmentMixin, self).__init__(
 | |
|             fqdn=socket.getfqdn(),
 | |
|             **kw
 | |
|             )
 | |
| 
 | |
| 
 | |
| class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
 | |
|     """Deduce pusher_email from pusher by appending an emaildomain."""
 | |
| 
 | |
|     def __init__(self, **kw):
 | |
|         super(PusherDomainEnvironmentMixin, self).__init__(**kw)
 | |
|         self.__emaildomain = self.config.get('emaildomain')
 | |
| 
 | |
|     def get_pusher_email(self):
 | |
|         if self.__emaildomain:
 | |
|             # Derive the pusher's full email address in the default way:
 | |
|             return '%s@%s' % (self.get_pusher(), self.__emaildomain)
 | |
|         else:
 | |
|             return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
 | |
| 
 | |
| 
 | |
| class StaticRecipientsEnvironmentMixin(Environment):
 | |
|     """Set recipients statically based on constructor parameters."""
 | |
| 
 | |
|     def __init__(
 | |
|             self,
 | |
|             refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
 | |
|             **kw
 | |
|             ):
 | |
|         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
 | |
| 
 | |
|         # The recipients for various types of notification emails, as
 | |
|         # RFC 2822 email addresses separated by commas (or the empty
 | |
|         # string if no recipients are configured).  Although there is
 | |
|         # a mechanism to choose the recipient lists based on on the
 | |
|         # actual *contents* of the change being reported, we only
 | |
|         # choose based on the *type* of the change.  Therefore we can
 | |
|         # compute them once and for all:
 | |
|         self.__refchange_recipients = refchange_recipients
 | |
|         self.__announce_recipients = announce_recipients
 | |
|         self.__revision_recipients = revision_recipients
 | |
| 
 | |
|     def check(self):
 | |
|         if not (self.get_refchange_recipients(None) or
 | |
|                 self.get_announce_recipients(None) or
 | |
|                 self.get_revision_recipients(None) or
 | |
|                 self.get_scancommitforcc()):
 | |
|             raise ConfigurationException('No email recipients configured!')
 | |
|         super(StaticRecipientsEnvironmentMixin, self).check()
 | |
| 
 | |
|     def get_refchange_recipients(self, refchange):
 | |
|         if self.__refchange_recipients is None:
 | |
|             return super(StaticRecipientsEnvironmentMixin,
 | |
|                          self).get_refchange_recipients(refchange)
 | |
|         return self.__refchange_recipients
 | |
| 
 | |
|     def get_announce_recipients(self, annotated_tag_change):
 | |
|         if self.__announce_recipients is None:
 | |
|             return super(StaticRecipientsEnvironmentMixin,
 | |
|                          self).get_refchange_recipients(annotated_tag_change)
 | |
|         return self.__announce_recipients
 | |
| 
 | |
|     def get_revision_recipients(self, revision):
 | |
|         if self.__revision_recipients is None:
 | |
|             return super(StaticRecipientsEnvironmentMixin,
 | |
|                          self).get_refchange_recipients(revision)
 | |
|         return self.__revision_recipients
 | |
| 
 | |
| 
 | |
| class CLIRecipientsEnvironmentMixin(Environment):
 | |
|     """Mixin storing recipients information coming from the
 | |
|     command-line."""
 | |
| 
 | |
|     def __init__(self, cli_recipients=None, **kw):
 | |
|         super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)
 | |
|         self.__cli_recipients = cli_recipients
 | |
| 
 | |
|     def get_refchange_recipients(self, refchange):
 | |
|         if self.__cli_recipients is None:
 | |
|             return super(CLIRecipientsEnvironmentMixin,
 | |
|                          self).get_refchange_recipients(refchange)
 | |
|         return self.__cli_recipients
 | |
| 
 | |
|     def get_announce_recipients(self, annotated_tag_change):
 | |
|         if self.__cli_recipients is None:
 | |
|             return super(CLIRecipientsEnvironmentMixin,
 | |
|                          self).get_announce_recipients(annotated_tag_change)
 | |
|         return self.__cli_recipients
 | |
| 
 | |
|     def get_revision_recipients(self, revision):
 | |
|         if self.__cli_recipients is None:
 | |
|             return super(CLIRecipientsEnvironmentMixin,
 | |
|                          self).get_revision_recipients(revision)
 | |
|         return self.__cli_recipients
 | |
| 
 | |
| 
 | |
| class ConfigRecipientsEnvironmentMixin(
 | |
|         ConfigEnvironmentMixin,
 | |
|         StaticRecipientsEnvironmentMixin
 | |
|         ):
 | |
|     """Determine recipients statically based on config."""
 | |
| 
 | |
|     def __init__(self, config, **kw):
 | |
|         super(ConfigRecipientsEnvironmentMixin, self).__init__(
 | |
|             config=config,
 | |
|             refchange_recipients=self._get_recipients(
 | |
|                 config, 'refchangelist', 'mailinglist',
 | |
|                 ),
 | |
|             announce_recipients=self._get_recipients(
 | |
|                 config, 'announcelist', 'refchangelist', 'mailinglist',
 | |
|                 ),
 | |
|             revision_recipients=self._get_recipients(
 | |
|                 config, 'commitlist', 'mailinglist',
 | |
|                 ),
 | |
|             scancommitforcc=config.get('scancommitforcc'),
 | |
|             **kw
 | |
|             )
 | |
| 
 | |
|     def _get_recipients(self, config, *names):
 | |
|         """Return the recipients for a particular type of message.
 | |
| 
 | |
|         Return the list of email addresses to which a particular type
 | |
|         of notification email should be sent, by looking at the config
 | |
|         value for "multimailhook.$name" for each of names.  Use the
 | |
|         value from the first name that is configured.  The return
 | |
|         value is a (possibly empty) string containing RFC 2822 email
 | |
|         addresses separated by commas.  If no configuration could be
 | |
|         found, raise a ConfigurationException."""
 | |
| 
 | |
|         for name in names:
 | |
|             lines = config.get_all(name)
 | |
|             if lines is not None:
 | |
|                 lines = [line.strip() for line in lines]
 | |
|                 # Single "none" is a special value equivalen to empty string.
 | |
|                 if lines == ['none']:
 | |
|                     lines = ['']
 | |
|                 return ', '.join(lines)
 | |
|         else:
 | |
|             return ''
 | |
| 
 | |
| 
 | |
| class StaticRefFilterEnvironmentMixin(Environment):
 | |
|     """Set branch filter statically based on constructor parameters."""
 | |
| 
 | |
|     def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
 | |
|                  ref_filter_do_send_regex, ref_filter_dont_send_regex,
 | |
|                  **kw):
 | |
|         super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
 | |
| 
 | |
|         if ref_filter_incl_regex and ref_filter_excl_regex:
 | |
|             raise ConfigurationException(
 | |
|                 "Cannot specify both a ref inclusion and exclusion regex.")
 | |
|         self.__is_inclusion_filter = bool(ref_filter_incl_regex)
 | |
|         default_exclude = self.get_default_ref_ignore_regex()
 | |
|         if ref_filter_incl_regex:
 | |
|             ref_filter_regex = ref_filter_incl_regex
 | |
|         elif ref_filter_excl_regex:
 | |
|             ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
 | |
|         else:
 | |
|             ref_filter_regex = default_exclude
 | |
|         try:
 | |
|             self.__compiled_regex = re.compile(ref_filter_regex)
 | |
|         except Exception:
 | |
|             raise ConfigurationException(
 | |
|                 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
 | |
| 
 | |
|         if ref_filter_do_send_regex and ref_filter_dont_send_regex:
 | |
|             raise ConfigurationException(
 | |
|                 "Cannot specify both a ref doSend and dontSend regex.")
 | |
|         self.__is_do_send_filter = bool(ref_filter_do_send_regex)
 | |
|         if ref_filter_do_send_regex:
 | |
|             ref_filter_send_regex = ref_filter_do_send_regex
 | |
|         elif ref_filter_dont_send_regex:
 | |
|             ref_filter_send_regex = ref_filter_dont_send_regex
 | |
|         else:
 | |
|             ref_filter_send_regex = '.*'
 | |
|             self.__is_do_send_filter = True
 | |
|         try:
 | |
|             self.__send_compiled_regex = re.compile(ref_filter_send_regex)
 | |
|         except Exception:
 | |
|             raise ConfigurationException(
 | |
|                 'Invalid Ref Filter Regex "%s": %s' %
 | |
|                 (ref_filter_send_regex, sys.exc_info()[1]))
 | |
| 
 | |
|     def get_ref_filter_regex(self, send_filter=False):
 | |
|         if send_filter:
 | |
|             return self.__send_compiled_regex, self.__is_do_send_filter
 | |
|         else:
 | |
|             return self.__compiled_regex, self.__is_inclusion_filter
 | |
| 
 | |
| 
 | |
| class ConfigRefFilterEnvironmentMixin(
 | |
|         ConfigEnvironmentMixin,
 | |
|         StaticRefFilterEnvironmentMixin
 | |
|         ):
 | |
|     """Determine branch filtering statically based on config."""
 | |
| 
 | |
|     def _get_regex(self, config, key):
 | |
|         """Get a list of whitespace-separated regex. The refFilter* config
 | |
|         variables are multivalued (hence the use of get_all), and we
 | |
|         allow each entry to be a whitespace-separated list (hence the
 | |
|         split on each line). The whole thing is glued into a single regex."""
 | |
|         values = config.get_all(key)
 | |
|         if values is None:
 | |
|             return values
 | |
|         items = []
 | |
|         for line in values:
 | |
|             for i in line.split():
 | |
|                 items.append(i)
 | |
|         if items == []:
 | |
|             return None
 | |
|         return '|'.join(items)
 | |
| 
 | |
|     def __init__(self, config, **kw):
 | |
|         super(ConfigRefFilterEnvironmentMixin, self).__init__(
 | |
|             config=config,
 | |
|             ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
 | |
|             ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
 | |
|             ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
 | |
|             ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
 | |
|             **kw
 | |
|             )
 | |
| 
 | |
| 
 | |
| class ProjectdescEnvironmentMixin(Environment):
 | |
|     """Make a "projectdesc" value available for templates.
 | |
| 
 | |
|     By default, it is set to the first line of $GIT_DIR/description
 | |
|     (if that file is present and appears to be set meaningfully)."""
 | |
| 
 | |
|     def __init__(self, **kw):
 | |
|         super(ProjectdescEnvironmentMixin, self).__init__(**kw)
 | |
|         self.COMPUTED_KEYS += ['projectdesc']
 | |
| 
 | |
|     def get_projectdesc(self):
 | |
|         """Return a one-line descripition of the project."""
 | |
| 
 | |
|         git_dir = get_git_dir()
 | |
|         try:
 | |
|             projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
 | |
|             if projectdesc and not projectdesc.startswith('Unnamed repository'):
 | |
|                 return projectdesc
 | |
|         except IOError:
 | |
|             pass
 | |
| 
 | |
|         return 'UNNAMED PROJECT'
 | |
| 
 | |
| 
 | |
| class GenericEnvironmentMixin(Environment):
 | |
|     def get_pusher(self):
 | |
|         return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
 | |
| 
 | |
| 
 | |
| class GitoliteEnvironmentHighPrecMixin(Environment):
 | |
|     def get_pusher(self):
 | |
|         return self.osenv.get('GL_USER', 'unknown user')
 | |
| 
 | |
| 
 | |
| class GitoliteEnvironmentLowPrecMixin(
 | |
|         ConfigEnvironmentMixin,
 | |
|         Environment):
 | |
| 
 | |
|     def get_repo_shortname(self):
 | |
|         # The gitolite environment variable $GL_REPO is a pretty good
 | |
|         # repo_shortname (though it's probably not as good as a value
 | |
|         # the user might have explicitly put in his config).
 | |
|         return (
 | |
|             self.osenv.get('GL_REPO', None) or
 | |
|             super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()
 | |
|             )
 | |
| 
 | |
|     @staticmethod
 | |
|     def _compile_regex(re_template):
 | |
|         return (
 | |
|             re.compile(re_template % x)
 | |
|             for x in (
 | |
|                 r'BEGIN\s+USER\s+EMAILS',
 | |
|                 r'([^\s]+)\s+(.*)',
 | |
|                 r'END\s+USER\s+EMAILS',
 | |
|                 ))
 | |
| 
 | |
|     def get_fromaddr(self, change=None):
 | |
|         GL_USER = self.osenv.get('GL_USER')
 | |
|         if GL_USER is not None:
 | |
|             # Find the path to gitolite.conf.  Note that gitolite v3
 | |
|             # did away with the GL_ADMINDIR and GL_CONF environment
 | |
|             # variables (they are now hard-coded).
 | |
|             GL_ADMINDIR = self.osenv.get(
 | |
|                 'GL_ADMINDIR',
 | |
|                 os.path.expanduser(os.path.join('~', '.gitolite')))
 | |
|             GL_CONF = self.osenv.get(
 | |
|                 'GL_CONF',
 | |
|                 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
 | |
| 
 | |
|             mailaddress_map = self.config.get('MailaddressMap')
 | |
|             # If relative, consider relative to GL_CONF:
 | |
|             if mailaddress_map:
 | |
|                 mailaddress_map = os.path.join(os.path.dirname(GL_CONF),
 | |
|                                                mailaddress_map)
 | |
|                 if os.path.isfile(mailaddress_map):
 | |
|                     f = open(mailaddress_map, 'rU')
 | |
|                     try:
 | |
|                         # Leading '#' is optional
 | |
|                         re_begin, re_user, re_end = self._compile_regex(
 | |
|                             r'^(?:\s*#)?\s*%s\s*$')
 | |
|                         for l in f:
 | |
|                             l = l.rstrip('\n')
 | |
|                             if re_begin.match(l) or re_end.match(l):
 | |
|                                 continue  # Ignore these lines
 | |
|                             m = re_user.match(l)
 | |
|                             if m:
 | |
|                                 if m.group(1) == GL_USER:
 | |
|                                     return m.group(2)
 | |
|                                 else:
 | |
|                                     continue  # Not this user, but not an error
 | |
|                             raise ConfigurationException(
 | |
|                                 "Syntax error in mail address map.\n"
 | |
|                                 "Check file {}.\n"
 | |
|                                 "Line: {}".format(mailaddress_map, l))
 | |
| 
 | |
|                     finally:
 | |
|                         f.close()
 | |
| 
 | |
|             if os.path.isfile(GL_CONF):
 | |
|                 f = open(GL_CONF, 'rU')
 | |
|                 try:
 | |
|                     in_user_emails_section = False
 | |
|                     re_begin, re_user, re_end = self._compile_regex(
 | |
|                         r'^\s*#\s*%s\s*$')
 | |
|                     for l in f:
 | |
|                         l = l.rstrip('\n')
 | |
|                         if not in_user_emails_section:
 | |
|                             if re_begin.match(l):
 | |
|                                 in_user_emails_section = True
 | |
|                             continue
 | |
|                         if re_end.match(l):
 | |
|                             break
 | |
|                         m = re_user.match(l)
 | |
|                         if m and m.group(1) == GL_USER:
 | |
|                             return m.group(2)
 | |
|                 finally:
 | |
|                     f.close()
 | |
|         return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)
 | |
| 
 | |
| 
 | |
| class IncrementalDateTime(object):
 | |
|     """Simple wrapper to give incremental date/times.
 | |
| 
 | |
|     Each call will result in a date/time a second later than the
 | |
|     previous call.  This can be used to falsify email headers, to
 | |
|     increase the likelihood that email clients sort the emails
 | |
|     correctly."""
 | |
| 
 | |
|     def __init__(self):
 | |
|         self.time = time.time()
 | |
|         self.next = self.__next__  # Python 2 backward compatibility
 | |
| 
 | |
|     def __next__(self):
 | |
|         formatted = formatdate(self.time, True)
 | |
|         self.time += 1
 | |
|         return formatted
 | |
| 
 | |
| 
 | |
| class StashEnvironmentHighPrecMixin(Environment):
 | |
|     def __init__(self, user=None, repo=None, **kw):
 | |
|         super(StashEnvironmentHighPrecMixin,
 | |
|               self).__init__(user=user, repo=repo, **kw)
 | |
|         self.__user = user
 | |
|         self.__repo = repo
 | |
| 
 | |
|     def get_pusher(self):
 | |
|         return re.match(r'(.*?)\s*<', self.__user).group(1)
 | |
| 
 | |
|     def get_pusher_email(self):
 | |
|         return self.__user
 | |
| 
 | |
| 
 | |
| class StashEnvironmentLowPrecMixin(Environment):
 | |
|     def __init__(self, user=None, repo=None, **kw):
 | |
|         super(StashEnvironmentLowPrecMixin, self).__init__(**kw)
 | |
|         self.__repo = repo
 | |
|         self.__user = user
 | |
| 
 | |
|     def get_repo_shortname(self):
 | |
|         return self.__repo
 | |
| 
 | |
|     def get_fromaddr(self, change=None):
 | |
|         return self.__user
 | |
| 
 | |
| 
 | |
| class GerritEnvironmentHighPrecMixin(Environment):
 | |
|     def __init__(self, project=None, submitter=None, update_method=None, **kw):
 | |
|         super(GerritEnvironmentHighPrecMixin,
 | |
|               self).__init__(submitter=submitter, project=project, **kw)
 | |
|         self.__project = project
 | |
|         self.__submitter = submitter
 | |
|         self.__update_method = update_method
 | |
|         "Make an 'update_method' value available for templates."
 | |
|         self.COMPUTED_KEYS += ['update_method']
 | |
| 
 | |
|     def get_pusher(self):
 | |
|         if self.__submitter:
 | |
|             if self.__submitter.find('<') != -1:
 | |
|                 # Submitter has a configured email, we transformed
 | |
|                 # __submitter into an RFC 2822 string already.
 | |
|                 return re.match(r'(.*?)\s*<', self.__submitter).group(1)
 | |
|             else:
 | |
|                 # Submitter has no configured email, it's just his name.
 | |
|                 return self.__submitter
 | |
|         else:
 | |
|             # If we arrive here, this means someone pushed "Submit" from
 | |
|             # the gerrit web UI for the CR (or used one of the programmatic
 | |
|             # APIs to do the same, such as gerrit review) and the
 | |
|             # merge/push was done by the Gerrit user.  It was technically
 | |
|             # triggered by someone else, but sadly we have no way of
 | |
|             # determining who that someone else is at this point.
 | |
|             return 'Gerrit'  # 'unknown user'?
 | |
| 
 | |
|     def get_pusher_email(self):
 | |
|         if self.__submitter:
 | |
|             return self.__submitter
 | |
|         else:
 | |
|             return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email()
 | |
| 
 | |
|     def get_default_ref_ignore_regex(self):
 | |
|         default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex()
 | |
|         return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
 | |
| 
 | |
|     def get_revision_recipients(self, revision):
 | |
|         # Merge commits created by Gerrit when users hit "Submit this patchset"
 | |
|         # in the Web UI (or do equivalently with REST APIs or the gerrit review
 | |
|         # command) are not something users want to see an individual email for.
 | |
|         # Filter them out.
 | |
|         committer = read_git_output(['log', '--no-walk', '--format=%cN',
 | |
|                                      revision.rev.sha1])
 | |
|         if committer == 'Gerrit Code Review':
 | |
|             return []
 | |
|         else:
 | |
|             return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision)
 | |
| 
 | |
|     def get_update_method(self):
 | |
|         return self.__update_method
 | |
| 
 | |
| 
 | |
| class GerritEnvironmentLowPrecMixin(Environment):
 | |
|     def __init__(self, project=None, submitter=None, **kw):
 | |
|         super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)
 | |
|         self.__project = project
 | |
|         self.__submitter = submitter
 | |
| 
 | |
|     def get_repo_shortname(self):
 | |
|         return self.__project
 | |
| 
 | |
|     def get_fromaddr(self, change=None):
 | |
|         if self.__submitter and self.__submitter.find('<') != -1:
 | |
|             return self.__submitter
 | |
|         else:
 | |
|             return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change)
 | |
| 
 | |
| 
 | |
| class Push(object):
 | |
|     """Represent an entire push (i.e., a group of ReferenceChanges).
 | |
| 
 | |
|     It is easy to figure out what commits were added to a *branch* by
 | |
|     a Reference change:
 | |
| 
 | |
|         git rev-list change.old..change.new
 | |
| 
 | |
|     or removed from a *branch*:
 | |
| 
 | |
|         git rev-list change.new..change.old
 | |
| 
 | |
|     But it is not quite so trivial to determine which entirely new
 | |
|     commits were added to the *repository* by a push and which old
 | |
|     commits were discarded by a push.  A big part of the job of this
 | |
|     class is to figure out these things, and to make sure that new
 | |
|     commits are only detailed once even if they were added to multiple
 | |
|     references.
 | |
| 
 | |
|     The first step is to determine the "other" references--those
 | |
|     unaffected by the current push.  They are computed by listing all
 | |
|     references then removing any affected by this push.  The results
 | |
|     are stored in Push._other_ref_sha1s.
 | |
| 
 | |
|     The commits contained in the repository before this push were
 | |
| 
 | |
|         git rev-list other1 other2 other3 ... change1.old change2.old ...
 | |
| 
 | |
|     Where "changeN.old" is the old value of one of the references
 | |
|     affected by this push.
 | |
| 
 | |
|     The commits contained in the repository after this push are
 | |
| 
 | |
|         git rev-list other1 other2 other3 ... change1.new change2.new ...
 | |
| 
 | |
|     The commits added by this push are the difference between these
 | |
|     two sets, which can be written
 | |
| 
 | |
|         git rev-list \
 | |
|             ^other1 ^other2 ... \
 | |
|             ^change1.old ^change2.old ... \
 | |
|             change1.new change2.new ...
 | |
| 
 | |
|     The commits removed by this push can be computed by
 | |
| 
 | |
|         git rev-list \
 | |
|             ^other1 ^other2 ... \
 | |
|             ^change1.new ^change2.new ... \
 | |
|             change1.old change2.old ...
 | |
| 
 | |
|     The last point is that it is possible that other pushes are
 | |
|     occurring simultaneously to this one, so reference values can
 | |
|     change at any time.  It is impossible to eliminate all race
 | |
|     conditions, but we reduce the window of time during which problems
 | |
|     can occur by translating reference names to SHA1s as soon as
 | |
|     possible and working with SHA1s thereafter (because SHA1s are
 | |
|     immutable)."""
 | |
| 
 | |
|     # A map {(changeclass, changetype): integer} specifying the order
 | |
|     # that reference changes will be processed if multiple reference
 | |
|     # changes are included in a single push.  The order is significant
 | |
|     # mostly because new commit notifications are threaded together
 | |
|     # with the first reference change that includes the commit.  The
 | |
|     # following order thus causes commits to be grouped with branch
 | |
|     # changes (as opposed to tag changes) if possible.
 | |
|     SORT_ORDER = dict(
 | |
|         (value, i) for (i, value) in enumerate([
 | |
|             (BranchChange, 'update'),
 | |
|             (BranchChange, 'create'),
 | |
|             (AnnotatedTagChange, 'update'),
 | |
|             (AnnotatedTagChange, 'create'),
 | |
|             (NonAnnotatedTagChange, 'update'),
 | |
|             (NonAnnotatedTagChange, 'create'),
 | |
|             (BranchChange, 'delete'),
 | |
|             (AnnotatedTagChange, 'delete'),
 | |
|             (NonAnnotatedTagChange, 'delete'),
 | |
|             (OtherReferenceChange, 'update'),
 | |
|             (OtherReferenceChange, 'create'),
 | |
|             (OtherReferenceChange, 'delete'),
 | |
|             ])
 | |
|         )
 | |
| 
 | |
|     def __init__(self, environment, changes, ignore_other_refs=False):
 | |
|         self.changes = sorted(changes, key=self._sort_key)
 | |
|         self.__other_ref_sha1s = None
 | |
|         self.__cached_commits_spec = {}
 | |
|         self.environment = environment
 | |
| 
 | |
|         if ignore_other_refs:
 | |
|             self.__other_ref_sha1s = set()
 | |
| 
 | |
|     @classmethod
 | |
|     def _sort_key(klass, change):
 | |
|         return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
 | |
| 
 | |
|     @property
 | |
|     def _other_ref_sha1s(self):
 | |
|         """The GitObjects referred to by references unaffected by this push.
 | |
|         """
 | |
|         if self.__other_ref_sha1s is None:
 | |
|             # The refnames being changed by this push:
 | |
|             updated_refs = set(
 | |
|                 change.refname
 | |
|                 for change in self.changes
 | |
|                 )
 | |
| 
 | |
|             # The SHA-1s of commits referred to by all references in this
 | |
|             # repository *except* updated_refs:
 | |
|             sha1s = set()
 | |
|             fmt = (
 | |
|                 '%(objectname) %(objecttype) %(refname)\n'
 | |
|                 '%(*objectname) %(*objecttype) %(refname)'
 | |
|                 )
 | |
|             ref_filter_regex, is_inclusion_filter = \
 | |
|                 self.environment.get_ref_filter_regex()
 | |
|             for line in read_git_lines(
 | |
|                     ['for-each-ref', '--format=%s' % (fmt,)]):
 | |
|                 (sha1, type, name) = line.split(' ', 2)
 | |
|                 if (sha1 and type == 'commit' and
 | |
|                         name not in updated_refs and
 | |
|                         include_ref(name, ref_filter_regex, is_inclusion_filter)):
 | |
|                     sha1s.add(sha1)
 | |
| 
 | |
|             self.__other_ref_sha1s = sha1s
 | |
| 
 | |
|         return self.__other_ref_sha1s
 | |
| 
 | |
|     def _get_commits_spec_incl(self, new_or_old, reference_change=None):
 | |
|         """Get new or old SHA-1 from one or each of the changed refs.
 | |
| 
 | |
|         Return a list of SHA-1 commit identifier strings suitable as
 | |
|         arguments to 'git rev-list' (or 'git log' or ...).  The
 | |
|         returned identifiers are either the old or new values from one
 | |
|         or all of the changed references, depending on the values of
 | |
|         new_or_old and reference_change.
 | |
| 
 | |
|         new_or_old is either the string 'new' or the string 'old'.  If
 | |
|         'new', the returned SHA-1 identifiers are the new values from
 | |
|         each changed reference.  If 'old', the SHA-1 identifiers are
 | |
|         the old values from each changed reference.
 | |
| 
 | |
|         If reference_change is specified and not None, only the new or
 | |
|         old reference from the specified reference is included in the
 | |
|         return value.
 | |
| 
 | |
|         This function returns None if there are no matching revisions
 | |
|         (e.g., because a branch was deleted and new_or_old is 'new').
 | |
|         """
 | |
| 
 | |
|         if not reference_change:
 | |
|             incl_spec = sorted(
 | |
|                 getattr(change, new_or_old).sha1
 | |
|                 for change in self.changes
 | |
|                 if getattr(change, new_or_old)
 | |
|                 )
 | |
|             if not incl_spec:
 | |
|                 incl_spec = None
 | |
|         elif not getattr(reference_change, new_or_old).commit_sha1:
 | |
|             incl_spec = None
 | |
|         else:
 | |
|             incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
 | |
|         return incl_spec
 | |
| 
 | |
|     def _get_commits_spec_excl(self, new_or_old):
 | |
|         """Get exclusion revisions for determining new or discarded commits.
 | |
| 
 | |
|         Return a list of strings suitable as arguments to 'git
 | |
|         rev-list' (or 'git log' or ...) that will exclude all
 | |
|         commits that, depending on the value of new_or_old, were
 | |
|         either previously in the repository (useful for determining
 | |
|         which commits are new to the repository) or currently in the
 | |
|         repository (useful for determining which commits were
 | |
|         discarded from the repository).
 | |
| 
 | |
|         new_or_old is either the string 'new' or the string 'old'.  If
 | |
|         'new', the commits to be excluded are those that were in the
 | |
|         repository before the push.  If 'old', the commits to be
 | |
|         excluded are those that are currently in the repository.  """
 | |
| 
 | |
|         old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
 | |
|         excl_revs = self._other_ref_sha1s.union(
 | |
|             getattr(change, old_or_new).sha1
 | |
|             for change in self.changes
 | |
|             if getattr(change, old_or_new).type in ['commit', 'tag']
 | |
|             )
 | |
|         return ['^' + sha1 for sha1 in sorted(excl_revs)]
 | |
| 
 | |
|     def get_commits_spec(self, new_or_old, reference_change=None):
 | |
|         """Get rev-list arguments for added or discarded commits.
 | |
| 
 | |
|         Return a list of strings suitable as arguments to 'git
 | |
|         rev-list' (or 'git log' or ...) that select those commits
 | |
|         that, depending on the value of new_or_old, are either new to
 | |
|         the repository or were discarded from the repository.
 | |
| 
 | |
|         new_or_old is either the string 'new' or the string 'old'.  If
 | |
|         'new', the returned list is used to select commits that are
 | |
|         new to the repository.  If 'old', the returned value is used
 | |
|         to select the commits that have been discarded from the
 | |
|         repository.
 | |
| 
 | |
|         If reference_change is specified and not None, the new or
 | |
|         discarded commits are limited to those that are reachable from
 | |
|         the new or old value of the specified reference.
 | |
| 
 | |
|         This function returns None if there are no added (or discarded)
 | |
|         revisions.
 | |
|         """
 | |
|         key = (new_or_old, reference_change)
 | |
|         if key not in self.__cached_commits_spec:
 | |
|             ret = self._get_commits_spec_incl(new_or_old, reference_change)
 | |
|             if ret is not None:
 | |
|                 ret.extend(self._get_commits_spec_excl(new_or_old))
 | |
|             self.__cached_commits_spec[key] = ret
 | |
|         return self.__cached_commits_spec[key]
 | |
| 
 | |
|     def get_new_commits(self, reference_change=None):
 | |
|         """Return a list of commits added by this push.
 | |
| 
 | |
|         Return a list of the object names of commits that were added
 | |
|         by the part of this push represented by reference_change.  If
 | |
|         reference_change is None, then return a list of *all* commits
 | |
|         added by this push."""
 | |
| 
 | |
|         spec = self.get_commits_spec('new', reference_change)
 | |
|         return git_rev_list(spec)
 | |
| 
 | |
|     def get_discarded_commits(self, reference_change):
 | |
|         """Return a list of commits discarded by this push.
 | |
| 
 | |
|         Return a list of the object names of commits that were
 | |
|         entirely discarded from the repository by the part of this
 | |
|         push represented by reference_change."""
 | |
| 
 | |
|         spec = self.get_commits_spec('old', reference_change)
 | |
|         return git_rev_list(spec)
 | |
| 
 | |
|     def send_emails(self, mailer, body_filter=None):
 | |
|         """Use send all of the notification emails needed for this push.
 | |
| 
 | |
|         Use send all of the notification emails (including reference
 | |
|         change emails and commit emails) needed for this push.  Send
 | |
|         the emails using mailer.  If body_filter is not None, then use
 | |
|         it to filter the lines that are intended for the email
 | |
|         body."""
 | |
| 
 | |
|         # The sha1s of commits that were introduced by this push.
 | |
|         # They will be removed from this set as they are processed, to
 | |
|         # guarantee that one (and only one) email is generated for
 | |
|         # each new commit.
 | |
|         unhandled_sha1s = set(self.get_new_commits())
 | |
|         send_date = IncrementalDateTime()
 | |
|         for change in self.changes:
 | |
|             sha1s = []
 | |
|             for sha1 in reversed(list(self.get_new_commits(change))):
 | |
|                 if sha1 in unhandled_sha1s:
 | |
|                     sha1s.append(sha1)
 | |
|                     unhandled_sha1s.remove(sha1)
 | |
| 
 | |
|             # Check if we've got anyone to send to
 | |
|             if not change.recipients:
 | |
|                 change.environment.log_warning(
 | |
|                     '*** no recipients configured so no email will be sent\n'
 | |
|                     '*** for %r update %s->%s'
 | |
|                     % (change.refname, change.old.sha1, change.new.sha1,)
 | |
|                     )
 | |
|             else:
 | |
|                 if not change.environment.quiet:
 | |
|                     change.environment.log_msg(
 | |
|                         'Sending notification emails to: %s' % (change.recipients,))
 | |
|                 extra_values = {'send_date': next(send_date)}
 | |
| 
 | |
|                 rev = change.send_single_combined_email(sha1s)
 | |
|                 if rev:
 | |
|                     mailer.send(
 | |
|                         change.generate_combined_email(self, rev, body_filter, extra_values),
 | |
|                         rev.recipients,
 | |
|                         )
 | |
|                     # This change is now fully handled; no need to handle
 | |
|                     # individual revisions any further.
 | |
|                     continue
 | |
|                 else:
 | |
|                     mailer.send(
 | |
|                         change.generate_email(self, body_filter, extra_values),
 | |
|                         change.recipients,
 | |
|                         )
 | |
| 
 | |
|             max_emails = change.environment.maxcommitemails
 | |
|             if max_emails and len(sha1s) > max_emails:
 | |
|                 change.environment.log_warning(
 | |
|                     '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
 | |
|                     '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
 | |
|                     '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails
 | |
|                     )
 | |
|                 return
 | |
| 
 | |
|             for (num, sha1) in enumerate(sha1s):
 | |
|                 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
 | |
|                 if len(rev.parents) > 1 and change.environment.excludemergerevisions:
 | |
|                     # skipping a merge commit
 | |
|                     continue
 | |
|                 if not rev.recipients and rev.cc_recipients:
 | |
|                     change.environment.log_msg('*** Replacing Cc: with To:')
 | |
|                     rev.recipients = rev.cc_recipients
 | |
|                     rev.cc_recipients = None
 | |
|                 if rev.recipients:
 | |
|                     extra_values = {'send_date': next(send_date)}
 | |
|                     mailer.send(
 | |
|                         rev.generate_email(self, body_filter, extra_values),
 | |
|                         rev.recipients,
 | |
|                         )
 | |
| 
 | |
|         # Consistency check:
 | |
|         if unhandled_sha1s:
 | |
|             change.environment.log_error(
 | |
|                 'ERROR: No emails were sent for the following new commits:\n'
 | |
|                 '    %s'
 | |
|                 % ('\n    '.join(sorted(unhandled_sha1s)),)
 | |
|                 )
 | |
| 
 | |
| 
 | |
| def include_ref(refname, ref_filter_regex, is_inclusion_filter):
 | |
|     does_match = bool(ref_filter_regex.search(refname))
 | |
|     if is_inclusion_filter:
 | |
|         return does_match
 | |
|     else:  # exclusion filter -- we include the ref if the regex doesn't match
 | |
|         return not does_match
 | |
| 
 | |
| 
 | |
| def run_as_post_receive_hook(environment, mailer):
 | |
|     environment.check()
 | |
|     send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
 | |
|     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
 | |
|     changes = []
 | |
|     while True:
 | |
|         line = read_line(sys.stdin)
 | |
|         if line == '':
 | |
|             break
 | |
|         (oldrev, newrev, refname) = line.strip().split(' ', 2)
 | |
|         environment.get_logger().debug(
 | |
|             "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" %
 | |
|             (oldrev, newrev, refname))
 | |
| 
 | |
|         if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
 | |
|             continue
 | |
|         if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
 | |
|             continue
 | |
|         changes.append(
 | |
|             ReferenceChange.create(environment, oldrev, newrev, refname)
 | |
|             )
 | |
|     if not changes:
 | |
|         mailer.close()
 | |
|         return
 | |
|     push = Push(environment, changes)
 | |
|     try:
 | |
|         push.send_emails(mailer, body_filter=environment.filter_body)
 | |
|     finally:
 | |
|         mailer.close()
 | |
| 
 | |
| 
 | |
| def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
 | |
|     environment.check()
 | |
|     send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
 | |
|     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
 | |
|     if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
 | |
|         return
 | |
|     if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
 | |
|         return
 | |
|     changes = [
 | |
|         ReferenceChange.create(
 | |
|             environment,
 | |
|             read_git_output(['rev-parse', '--verify', oldrev]),
 | |
|             read_git_output(['rev-parse', '--verify', newrev]),
 | |
|             refname,
 | |
|             ),
 | |
|         ]
 | |
|     if not changes:
 | |
|         mailer.close()
 | |
|         return
 | |
|     push = Push(environment, changes, force_send)
 | |
|     try:
 | |
|         push.send_emails(mailer, body_filter=environment.filter_body)
 | |
|     finally:
 | |
|         mailer.close()
 | |
| 
 | |
| 
 | |
| def check_ref_filter(environment):
 | |
|     send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True)
 | |
|     ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False)
 | |
| 
 | |
|     def inc_exc_lusion(b):
 | |
|         if b:
 | |
|             return 'inclusion'
 | |
|         else:
 | |
|             return 'exclusion'
 | |
| 
 | |
|     if send_filter_regex:
 | |
|         sys.stdout.write("DoSend/DontSend filter regex (" +
 | |
|                          (inc_exc_lusion(send_is_inclusion)) +
 | |
|                          '): ' + send_filter_regex.pattern +
 | |
|                          '\n')
 | |
|     if send_filter_regex:
 | |
|         sys.stdout.write("Include/Exclude filter regex (" +
 | |
|                          (inc_exc_lusion(ref_is_inclusion)) +
 | |
|                          '): ' + ref_filter_regex.pattern +
 | |
|                          '\n')
 | |
|     sys.stdout.write(os.linesep)
 | |
| 
 | |
|     sys.stdout.write(
 | |
|         "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"
 | |
|         "or refFilterExclusionRegex. No emails will be sent for commits included\n"
 | |
|         "in these refs.\n"
 | |
|         "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"
 | |
|         "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"
 | |
|         "refFilterExclusionRegex. Emails will be sent for commits included in these\n"
 | |
|         "refs only when the commit reaches a ref which isn't excluded.\n"
 | |
|         "Refs marked as DO-SEND are not excluded by any filter. Emails will\n"
 | |
|         "be sent normally for commits included in these refs.\n")
 | |
| 
 | |
|     sys.stdout.write(os.linesep)
 | |
| 
 | |
|     for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']):
 | |
|         sys.stdout.write(refname)
 | |
|         if not include_ref(refname, ref_filter_regex, ref_is_inclusion):
 | |
|             sys.stdout.write(' EXCLUDE')
 | |
|         elif not include_ref(refname, send_filter_regex, send_is_inclusion):
 | |
|             sys.stdout.write(' DONT-SEND')
 | |
|         else:
 | |
|             sys.stdout.write(' DO-SEND')
 | |
| 
 | |
|         sys.stdout.write(os.linesep)
 | |
| 
 | |
| 
 | |
| def show_env(environment, out):
 | |
|     out.write('Environment values:\n')
 | |
|     for (k, v) in sorted(environment.get_values().items()):
 | |
|         if k:  # Don't show the {'' : ''} pair.
 | |
|             out.write('    %s : %r\n' % (k, v))
 | |
|     out.write('\n')
 | |
|     # Flush to avoid interleaving with further log output
 | |
|     out.flush()
 | |
| 
 | |
| 
 | |
| def check_setup(environment):
 | |
|     environment.check()
 | |
|     show_env(environment, sys.stdout)
 | |
|     sys.stdout.write("Now, checking that git-multimail's standard input "
 | |
|                      "is properly set ..." + os.linesep)
 | |
|     sys.stdout.write("Please type some text and then press Return" + os.linesep)
 | |
|     stdin = sys.stdin.readline()
 | |
|     sys.stdout.write("You have just entered:" + os.linesep)
 | |
|     sys.stdout.write(stdin)
 | |
|     sys.stdout.write("git-multimail seems properly set up." + os.linesep)
 | |
| 
 | |
| 
 | |
| def choose_mailer(config, environment):
 | |
|     mailer = config.get('mailer', default='sendmail')
 | |
| 
 | |
|     if mailer == 'smtp':
 | |
|         smtpserver = config.get('smtpserver', default='localhost')
 | |
|         smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
 | |
|         smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
 | |
|         smtpencryption = config.get('smtpencryption', default='none')
 | |
|         smtpuser = config.get('smtpuser', default='')
 | |
|         smtppass = config.get('smtppass', default='')
 | |
|         smtpcacerts = config.get('smtpcacerts', default='')
 | |
|         mailer = SMTPMailer(
 | |
|             environment,
 | |
|             envelopesender=(environment.get_sender() or environment.get_fromaddr()),
 | |
|             smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
 | |
|             smtpserverdebuglevel=smtpserverdebuglevel,
 | |
|             smtpencryption=smtpencryption,
 | |
|             smtpuser=smtpuser,
 | |
|             smtppass=smtppass,
 | |
|             smtpcacerts=smtpcacerts
 | |
|             )
 | |
|     elif mailer == 'sendmail':
 | |
|         command = config.get('sendmailcommand')
 | |
|         if command:
 | |
|             command = shlex.split(command)
 | |
|         mailer = SendMailer(environment,
 | |
|                             command=command, envelopesender=environment.get_sender())
 | |
|     else:
 | |
|         environment.log_error(
 | |
|             'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
 | |
|             'please use one of "smtp" or "sendmail".'
 | |
|             )
 | |
|         sys.exit(1)
 | |
|     return mailer
 | |
| 
 | |
| 
 | |
| KNOWN_ENVIRONMENTS = {
 | |
|     'generic': {'highprec': GenericEnvironmentMixin},
 | |
|     'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin,
 | |
|                  'lowprec': GitoliteEnvironmentLowPrecMixin},
 | |
|     'stash': {'highprec': StashEnvironmentHighPrecMixin,
 | |
|               'lowprec': StashEnvironmentLowPrecMixin},
 | |
|     'gerrit': {'highprec': GerritEnvironmentHighPrecMixin,
 | |
|                'lowprec': GerritEnvironmentLowPrecMixin},
 | |
|     }
 | |
| 
 | |
| 
 | |
| def choose_environment(config, osenv=None, env=None, recipients=None,
 | |
|                        hook_info=None):
 | |
|     env_name = choose_environment_name(config, env, osenv)
 | |
|     environment_klass = build_environment_klass(env_name)
 | |
|     env = build_environment(environment_klass, env_name, config,
 | |
|                             osenv, recipients, hook_info)
 | |
|     return env
 | |
| 
 | |
| 
 | |
| def choose_environment_name(config, env, osenv):
 | |
|     if not osenv:
 | |
|         osenv = os.environ
 | |
| 
 | |
|     if not env:
 | |
|         env = config.get('environment')
 | |
| 
 | |
|     if not env:
 | |
|         if 'GL_USER' in osenv and 'GL_REPO' in osenv:
 | |
|             env = 'gitolite'
 | |
|         else:
 | |
|             env = 'generic'
 | |
|     return env
 | |
| 
 | |
| 
 | |
| COMMON_ENVIRONMENT_MIXINS = [
 | |
|     ConfigRecipientsEnvironmentMixin,
 | |
|     CLIRecipientsEnvironmentMixin,
 | |
|     ConfigRefFilterEnvironmentMixin,
 | |
|     ProjectdescEnvironmentMixin,
 | |
|     ConfigMaxlinesEnvironmentMixin,
 | |
|     ComputeFQDNEnvironmentMixin,
 | |
|     ConfigFilterLinesEnvironmentMixin,
 | |
|     PusherDomainEnvironmentMixin,
 | |
|     ConfigOptionsEnvironmentMixin,
 | |
|     ]
 | |
| 
 | |
| 
 | |
| def build_environment_klass(env_name):
 | |
|     if 'class' in KNOWN_ENVIRONMENTS[env_name]:
 | |
|         return KNOWN_ENVIRONMENTS[env_name]['class']
 | |
| 
 | |
|     environment_mixins = []
 | |
|     known_env = KNOWN_ENVIRONMENTS[env_name]
 | |
|     if 'highprec' in known_env:
 | |
|         high_prec_mixin = known_env['highprec']
 | |
|         environment_mixins.append(high_prec_mixin)
 | |
|     environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS
 | |
|     if 'lowprec' in known_env:
 | |
|         low_prec_mixin = known_env['lowprec']
 | |
|         environment_mixins.append(low_prec_mixin)
 | |
|     environment_mixins.append(Environment)
 | |
|     klass_name = env_name.capitalize() + 'Environment'
 | |
|     environment_klass = type(
 | |
|         klass_name,
 | |
|         tuple(environment_mixins),
 | |
|         {},
 | |
|         )
 | |
|     KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass
 | |
|     return environment_klass
 | |
| 
 | |
| 
 | |
| GerritEnvironment = build_environment_klass('gerrit')
 | |
| StashEnvironment = build_environment_klass('stash')
 | |
| GitoliteEnvironment = build_environment_klass('gitolite')
 | |
| GenericEnvironment = build_environment_klass('generic')
 | |
| 
 | |
| 
 | |
| def build_environment(environment_klass, env, config,
 | |
|                       osenv, recipients, hook_info):
 | |
|     environment_kw = {
 | |
|         'osenv': osenv,
 | |
|         'config': config,
 | |
|         }
 | |
| 
 | |
|     if env == 'stash':
 | |
|         environment_kw['user'] = hook_info['stash_user']
 | |
|         environment_kw['repo'] = hook_info['stash_repo']
 | |
|     elif env == 'gerrit':
 | |
|         environment_kw['project'] = hook_info['project']
 | |
|         environment_kw['submitter'] = hook_info['submitter']
 | |
|         environment_kw['update_method'] = hook_info['update_method']
 | |
| 
 | |
|     environment_kw['cli_recipients'] = recipients
 | |
| 
 | |
|     return environment_klass(**environment_kw)
 | |
| 
 | |
| 
 | |
| def get_version():
 | |
|     oldcwd = os.getcwd()
 | |
|     try:
 | |
|         try:
 | |
|             os.chdir(os.path.dirname(os.path.realpath(__file__)))
 | |
|             git_version = read_git_output(['describe', '--tags', 'HEAD'])
 | |
|             if git_version == __version__:
 | |
|                 return git_version
 | |
|             else:
 | |
|                 return '%s (%s)' % (__version__, git_version)
 | |
|         except:
 | |
|             pass
 | |
|     finally:
 | |
|         os.chdir(oldcwd)
 | |
|     return __version__
 | |
| 
 | |
| 
 | |
| def compute_gerrit_options(options, args, required_gerrit_options,
 | |
|                            raw_refname):
 | |
|     if None in required_gerrit_options:
 | |
|         raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
 | |
|                          "and --project; or none of them.")
 | |
| 
 | |
|     if options.environment not in (None, 'gerrit'):
 | |
|         raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
 | |
|                          "--newrev, --refname, and --project")
 | |
|     options.environment = 'gerrit'
 | |
| 
 | |
|     if args:
 | |
|         raise SystemExit("Error: Positional parameters not allowed with "
 | |
|                          "--oldrev, --newrev, and --refname.")
 | |
| 
 | |
|     # Gerrit oddly omits 'refs/heads/' in the refname when calling
 | |
|     # ref-updated hook; put it back.
 | |
|     git_dir = get_git_dir()
 | |
|     if (not os.path.exists(os.path.join(git_dir, raw_refname)) and
 | |
|         os.path.exists(os.path.join(git_dir, 'refs', 'heads',
 | |
|                                     raw_refname))):
 | |
|         options.refname = 'refs/heads/' + options.refname
 | |
| 
 | |
|     # New revisions can appear in a gerrit repository either due to someone
 | |
|     # pushing directly (in which case options.submitter will be set), or they
 | |
|     # can press "Submit this patchset" in the web UI for some CR (in which
 | |
|     # case options.submitter will not be set and gerrit will not have provided
 | |
|     # us the information about who pressed the button).
 | |
|     #
 | |
|     # Note for the nit-picky: I'm lumping in REST API calls and the ssh
 | |
|     # gerrit review command in with "Submit this patchset" button, since they
 | |
|     # have the same effect.
 | |
|     if options.submitter:
 | |
|         update_method = 'pushed'
 | |
|         # The submitter argument is almost an RFC 2822 email address; change it
 | |
|         # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
 | |
|         options.submitter = options.submitter.replace('(', '<').replace(')', '>')
 | |
|     else:
 | |
|         update_method = 'submitted'
 | |
|         # Gerrit knew who submitted this patchset, but threw that information
 | |
|         # away when it invoked this hook.  However, *IF* Gerrit created a
 | |
|         # merge to bring the patchset in (project 'Submit Type' is either
 | |
|         # "Always Merge", or is "Merge if Necessary" and happens to be
 | |
|         # necessary for this particular CR), then it will have the committer
 | |
|         # of that merge be 'Gerrit Code Review' and the author will be the
 | |
|         # person who requested the submission of the CR.  Since this is fairly
 | |
|         # likely for most gerrit installations (of a reasonable size), it's
 | |
|         # worth the extra effort to try to determine the actual submitter.
 | |
|         rev_info = read_git_lines(['log', '--no-walk', '--merges',
 | |
|                                    '--format=%cN%n%aN <%aE>', options.newrev])
 | |
|         if rev_info and rev_info[0] == 'Gerrit Code Review':
 | |
|             options.submitter = rev_info[1]
 | |
| 
 | |
|     # We pass back refname, oldrev, newrev as args because then the
 | |
|     # gerrit ref-updated hook is much like the git update hook
 | |
|     return (options,
 | |
|             [options.refname, options.oldrev, options.newrev],
 | |
|             {'project': options.project, 'submitter': options.submitter,
 | |
|              'update_method': update_method})
 | |
| 
 | |
| 
 | |
| def check_hook_specific_args(options, args):
 | |
|     raw_refname = options.refname
 | |
|     # Convert each string option unicode for Python3.
 | |
|     if PYTHON3:
 | |
|         opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
 | |
|                 'project', 'submitter', 'stash_user', 'stash_repo']
 | |
|         for opt in opts:
 | |
|             if not hasattr(options, opt):
 | |
|                 continue
 | |
|             obj = getattr(options, opt)
 | |
|             if obj:
 | |
|                 enc = obj.encode('utf-8', 'surrogateescape')
 | |
|                 dec = enc.decode('utf-8', 'replace')
 | |
|                 setattr(options, opt, dec)
 | |
| 
 | |
|     # First check for stash arguments
 | |
|     if (options.stash_user is None) != (options.stash_repo is None):
 | |
|         raise SystemExit("Error: Specify both of --stash-user and "
 | |
|                          "--stash-repo or neither.")
 | |
|     if options.stash_user:
 | |
|         options.environment = 'stash'
 | |
|         return options, args, {'stash_user': options.stash_user,
 | |
|                                'stash_repo': options.stash_repo}
 | |
| 
 | |
|     # Finally, check for gerrit specific arguments
 | |
|     required_gerrit_options = (options.oldrev, options.newrev, options.refname,
 | |
|                                options.project)
 | |
|     if required_gerrit_options != (None,) * 4:
 | |
|         return compute_gerrit_options(options, args, required_gerrit_options,
 | |
|                                       raw_refname)
 | |
| 
 | |
|     # No special options in use, just return what we started with
 | |
|     return options, args, {}
 | |
| 
 | |
| 
 | |
| class Logger(object):
 | |
|     def parse_verbose(self, verbose):
 | |
|         if verbose > 0:
 | |
|             return logging.DEBUG
 | |
|         else:
 | |
|             return logging.INFO
 | |
| 
 | |
|     def create_log_file(self, environment, name, path, verbosity):
 | |
|         log_file = logging.getLogger(name)
 | |
|         file_handler = logging.FileHandler(path)
 | |
|         log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s]  %(message)s")
 | |
|         file_handler.setFormatter(log_fmt)
 | |
|         log_file.addHandler(file_handler)
 | |
|         log_file.setLevel(verbosity)
 | |
|         return log_file
 | |
| 
 | |
|     def __init__(self, environment):
 | |
|         self.environment = environment
 | |
|         self.loggers = []
 | |
|         stderr_log = logging.getLogger('git_multimail.stderr')
 | |
| 
 | |
|         class EncodedStderr(object):
 | |
|             def write(self, x):
 | |
|                 write_str(sys.stderr, x)
 | |
| 
 | |
|             def flush(self):
 | |
|                 sys.stderr.flush()
 | |
| 
 | |
|         stderr_handler = logging.StreamHandler(EncodedStderr())
 | |
|         stderr_log.addHandler(stderr_handler)
 | |
|         stderr_log.setLevel(self.parse_verbose(environment.verbose))
 | |
|         self.loggers.append(stderr_log)
 | |
| 
 | |
|         if environment.debug_log_file is not None:
 | |
|             debug_log_file = self.create_log_file(
 | |
|                 environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG)
 | |
|             self.loggers.append(debug_log_file)
 | |
| 
 | |
|         if environment.log_file is not None:
 | |
|             log_file = self.create_log_file(
 | |
|                 environment, 'git_multimail.file', environment.log_file, logging.INFO)
 | |
|             self.loggers.append(log_file)
 | |
| 
 | |
|         if environment.error_log_file is not None:
 | |
|             error_log_file = self.create_log_file(
 | |
|                 environment, 'git_multimail.error', environment.error_log_file, logging.ERROR)
 | |
|             self.loggers.append(error_log_file)
 | |
| 
 | |
|     def info(self, msg, *args, **kwargs):
 | |
|         for l in self.loggers:
 | |
|             l.info(msg, *args, **kwargs)
 | |
| 
 | |
|     def debug(self, msg, *args, **kwargs):
 | |
|         for l in self.loggers:
 | |
|             l.debug(msg, *args, **kwargs)
 | |
| 
 | |
|     def warning(self, msg, *args, **kwargs):
 | |
|         for l in self.loggers:
 | |
|             l.warning(msg, *args, **kwargs)
 | |
| 
 | |
|     def error(self, msg, *args, **kwargs):
 | |
|         for l in self.loggers:
 | |
|             l.error(msg, *args, **kwargs)
 | |
| 
 | |
| 
 | |
| def main(args):
 | |
|     parser = optparse.OptionParser(
 | |
|         description=__doc__,
 | |
|         usage='%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
 | |
|         )
 | |
| 
 | |
|     parser.add_option(
 | |
|         '--environment', '--env', action='store', type='choice',
 | |
|         choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
 | |
|         help=(
 | |
|             'Choose type of environment is in use.  Default is taken from '
 | |
|             'multimailhook.environment if set; otherwise "generic".'
 | |
|             ),
 | |
|         )
 | |
|     parser.add_option(
 | |
|         '--stdout', action='store_true', default=False,
 | |
|         help='Output emails to stdout rather than sending them.',
 | |
|         )
 | |
|     parser.add_option(
 | |
|         '--recipients', action='store', default=None,
 | |
|         help='Set list of email recipients for all types of emails.',
 | |
|         )
 | |
|     parser.add_option(
 | |
|         '--show-env', action='store_true', default=False,
 | |
|         help=(
 | |
|             'Write to stderr the values determined for the environment '
 | |
|             '(intended for debugging purposes), then proceed normally.'
 | |
|             ),
 | |
|         )
 | |
|     parser.add_option(
 | |
|         '--force-send', action='store_true', default=False,
 | |
|         help=(
 | |
|             'Force sending refchange email when using as an update hook. '
 | |
|             'This is useful to work around the unreliable new commits '
 | |
|             'detection in this mode.'
 | |
|             ),
 | |
|         )
 | |
|     parser.add_option(
 | |
|         '-c', metavar="<name>=<value>", action='append',
 | |
|         help=(
 | |
|             'Pass a configuration parameter through to git.  The value given '
 | |
|             'will override values from configuration files.  See the -c option '
 | |
|             'of git(1) for more details.  (Only works with git >= 1.7.3)'
 | |
|             ),
 | |
|         )
 | |
|     parser.add_option(
 | |
|         '--version', '-v', action='store_true', default=False,
 | |
|         help=(
 | |
|             "Display git-multimail's version"
 | |
|             ),
 | |
|         )
 | |
| 
 | |
|     parser.add_option(
 | |
|         '--python-version', action='store_true', default=False,
 | |
|         help=(
 | |
|             "Display the version of Python used by git-multimail"
 | |
|             ),
 | |
|         )
 | |
| 
 | |
|     parser.add_option(
 | |
|         '--check-ref-filter', action='store_true', default=False,
 | |
|         help=(
 | |
|             'List refs and show information on how git-multimail '
 | |
|             'will process them.'
 | |
|             )
 | |
|         )
 | |
| 
 | |
|     # The following options permit this script to be run as a gerrit
 | |
|     # ref-updated hook.  See e.g.
 | |
|     # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
 | |
|     # We suppress help for these items, since these are specific to gerrit,
 | |
|     # and we don't want users directly using them any way other than how the
 | |
|     # gerrit ref-updated hook is called.
 | |
|     parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
 | |
|     parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
 | |
|     parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
 | |
|     parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
 | |
|     parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
 | |
| 
 | |
|     # The following allow this to be run as a stash asynchronous post-receive
 | |
|     # hook (almost identical to a git post-receive hook but triggered also for
 | |
|     # merges of pull requests from the UI).  We suppress help for these items,
 | |
|     # since these are specific to stash.
 | |
|     parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
 | |
|     parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
 | |
| 
 | |
|     (options, args) = parser.parse_args(args)
 | |
|     (options, args, hook_info) = check_hook_specific_args(options, args)
 | |
| 
 | |
|     if options.version:
 | |
|         sys.stdout.write('git-multimail version ' + get_version() + '\n')
 | |
|         return
 | |
| 
 | |
|     if options.python_version:
 | |
|         sys.stdout.write('Python version ' + sys.version + '\n')
 | |
|         return
 | |
| 
 | |
|     if options.c:
 | |
|         Config.add_config_parameters(options.c)
 | |
| 
 | |
|     config = Config('multimailhook')
 | |
| 
 | |
|     environment = None
 | |
|     try:
 | |
|         environment = choose_environment(
 | |
|             config, osenv=os.environ,
 | |
|             env=options.environment,
 | |
|             recipients=options.recipients,
 | |
|             hook_info=hook_info,
 | |
|             )
 | |
| 
 | |
|         if options.show_env:
 | |
|             show_env(environment, sys.stderr)
 | |
| 
 | |
|         if options.stdout or environment.stdout:
 | |
|             mailer = OutputMailer(sys.stdout, environment)
 | |
|         else:
 | |
|             mailer = choose_mailer(config, environment)
 | |
| 
 | |
|         must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP')
 | |
|         if must_check_setup == '':
 | |
|             must_check_setup = False
 | |
|         if options.check_ref_filter:
 | |
|             check_ref_filter(environment)
 | |
|         elif must_check_setup:
 | |
|             check_setup(environment)
 | |
|         # Dual mode: if arguments were specified on the command line, run
 | |
|         # like an update hook; otherwise, run as a post-receive hook.
 | |
|         elif args:
 | |
|             if len(args) != 3:
 | |
|                 parser.error('Need zero or three non-option arguments')
 | |
|             (refname, oldrev, newrev) = args
 | |
|             environment.get_logger().debug(
 | |
|                 "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" %
 | |
|                 (refname, oldrev, newrev, options.force_send))
 | |
|             run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
 | |
|         else:
 | |
|             run_as_post_receive_hook(environment, mailer)
 | |
|     except ConfigurationException:
 | |
|         sys.exit(sys.exc_info()[1])
 | |
|     except SystemExit:
 | |
|         raise
 | |
|     except Exception:
 | |
|         t, e, tb = sys.exc_info()
 | |
|         import traceback
 | |
|         sys.stderr.write('\n')  # Avoid mixing message with previous output
 | |
|         msg = (
 | |
|             'Exception \'' + t.__name__ +
 | |
|             '\' raised. Please report this as a bug to\n'
 | |
|             'https://github.com/git-multimail/git-multimail/issues\n'
 | |
|             'with the information below:\n\n'
 | |
|             'git-multimail version ' + get_version() + '\n'
 | |
|             'Python version ' + sys.version + '\n' +
 | |
|             traceback.format_exc())
 | |
|         try:
 | |
|             environment.get_logger().error(msg)
 | |
|         except:
 | |
|             sys.stderr.write(msg)
 | |
|         sys.exit(1)
 | |
| 
 | |
| 
 | |
| if __name__ == '__main__':
 | |
|     main(sys.argv[1:])
 |