From 0ea383bf20cf11bbbde2b1e868278d270b314ebc Mon Sep 17 00:00:00 2001 From: Dan Fandrich Date: Sat, 13 Jan 2024 01:20:22 -0800 Subject: Copy git_multimail.py as-is Don't try to perform template variable substitution because there are strings in the file that look like substitutions but aren't. --- deployment/mgagit/files/git_multimail.py | 4383 ++++++++++++++++++++++++++ deployment/mgagit/manifests/init.pp | 2 +- deployment/mgagit/templates/git_multimail.py | 4383 -------------------------- 3 files changed, 4384 insertions(+), 4384 deletions(-) create mode 100644 deployment/mgagit/files/git_multimail.py delete mode 100644 deployment/mgagit/templates/git_multimail.py (limited to 'deployment/mgagit') diff --git a/deployment/mgagit/files/git_multimail.py b/deployment/mgagit/files/git_multimail.py new file mode 100644 index 00000000..01acf6da --- /dev/null +++ b/deployment/mgagit/files/git_multimail.py @@ -0,0 +1,4383 @@ +#! /usr/bin/env python2 + +__version__ = '1.7.dev' + +# Copyright (c) 2015-2022 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 +# . + +"""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 outputting 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 = """\ +

View the commit online.

+""" + + +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
 tag when using HTML format.
+
+        Escape special HTML characters and add 
 and 
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 "
\n"
+
+            for line in lines:
+                yield html_escape(line)
+
+            yield '
\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 '
'
+        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 
.
+                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 %s\n" % (style, line)
+                else:
+                    line = line + '\n'
+
+            yield line
+        if self._contains_html_diff:
+            yield '
' + 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[^#]+@[^\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[^\/]+)\/(?P.*)$') + + @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 access 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 certificate 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 = self.smtp.sock.makefile('rb') + 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.encode('utf8')) + 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 " + 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 + '.) + + 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.+?)(?:\.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_val: + 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=self.get_fqdn(), + **kw + ) + + def get_fqdn(self): + fqdn = socket.getfqdn() + # Sometimes, socket.getfqdn() returns localhost or + # localhost.localhost, which isn't very helpful. In this case, + # fall-back to socket.gethostname() which may return an actual + # hostname. + if fqdn == 'localhost' or fqdn == 'localhost.localdomain': + fqdn = socket.gethostname() + return fqdn + + +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 description 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 GiteaEnvironmentHighPrecMixin(Environment): + def get_pusher(self): + return self.osenv.get('GITEA_PUSHER_NAME', 'unknown user') + + def get_pusher_email(self): + return self.osenv.get('GITEA_PUSHER_EMAIL') + + +class GiteaEnvironmentLowPrecMixin(Environment): + def get_repo_shortname(self): + return self.osenv.get('GITEA_REPO_NAME', 'unknown repository') + + def get_fromaddr(self, change=None): + # GITEA_PUSHER_NAME doesn't include the full name, just the user name + # at Gitea level, so it doesn't seem useful to use it and there + # doesn't seem to be any simple way to get it from Gitea neither. + return self.osenv.get('GITEA_PUSHER_EMAIL') + + +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: + # mga: avoid unnecessary error messages when the summary + # email address is not configured (used for i18n mails). +# 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,) +# ) + pass + 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}, + 'gitea': {'highprec': GiteaEnvironmentHighPrecMixin, + 'lowprec': GiteaEnvironmentLowPrecMixin}, + } + + +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' + elif 'GITEA_PUSHER_NAME' in osenv and 'GITEA_REPO_NAME' in osenv: + env = 'gitea' + 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 + + +GiteaEnvironment = build_environment_klass('gitea') +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 ' 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="=", 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:]) diff --git a/deployment/mgagit/manifests/init.pp b/deployment/mgagit/manifests/init.pp index 0376768b..2ad9cd64 100644 --- a/deployment/mgagit/manifests/init.pp +++ b/deployment/mgagit/manifests/init.pp @@ -48,7 +48,7 @@ class mgagit( owner => $git_login, group => $git_login, mode => '0644', - content => template('mgagit/git_multimail.py'), + content => file('mgagit/git_multimail.py'), require => File[$gitolite_commonhooksdir], } diff --git a/deployment/mgagit/templates/git_multimail.py b/deployment/mgagit/templates/git_multimail.py deleted file mode 100644 index 01acf6da..00000000 --- a/deployment/mgagit/templates/git_multimail.py +++ /dev/null @@ -1,4383 +0,0 @@ -#! /usr/bin/env python2 - -__version__ = '1.7.dev' - -# Copyright (c) 2015-2022 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 -# . - -"""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 outputting 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 = """\ -

View the commit online.

-""" - - -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
 tag when using HTML format.
-
-        Escape special HTML characters and add 
 and 
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 "
\n"
-
-            for line in lines:
-                yield html_escape(line)
-
-            yield '
\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 '
'
-        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 
.
-                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 %s\n" % (style, line)
-                else:
-                    line = line + '\n'
-
-            yield line
-        if self._contains_html_diff:
-            yield '
' - 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[^#]+@[^\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[^\/]+)\/(?P.*)$') - - @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 access 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 certificate 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 = self.smtp.sock.makefile('rb') - 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.encode('utf8')) - 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 " - 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 - '.) - - 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.+?)(?:\.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_val: - 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=self.get_fqdn(), - **kw - ) - - def get_fqdn(self): - fqdn = socket.getfqdn() - # Sometimes, socket.getfqdn() returns localhost or - # localhost.localhost, which isn't very helpful. In this case, - # fall-back to socket.gethostname() which may return an actual - # hostname. - if fqdn == 'localhost' or fqdn == 'localhost.localdomain': - fqdn = socket.gethostname() - return fqdn - - -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 description 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 GiteaEnvironmentHighPrecMixin(Environment): - def get_pusher(self): - return self.osenv.get('GITEA_PUSHER_NAME', 'unknown user') - - def get_pusher_email(self): - return self.osenv.get('GITEA_PUSHER_EMAIL') - - -class GiteaEnvironmentLowPrecMixin(Environment): - def get_repo_shortname(self): - return self.osenv.get('GITEA_REPO_NAME', 'unknown repository') - - def get_fromaddr(self, change=None): - # GITEA_PUSHER_NAME doesn't include the full name, just the user name - # at Gitea level, so it doesn't seem useful to use it and there - # doesn't seem to be any simple way to get it from Gitea neither. - return self.osenv.get('GITEA_PUSHER_EMAIL') - - -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: - # mga: avoid unnecessary error messages when the summary - # email address is not configured (used for i18n mails). -# 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,) -# ) - pass - 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}, - 'gitea': {'highprec': GiteaEnvironmentHighPrecMixin, - 'lowprec': GiteaEnvironmentLowPrecMixin}, - } - - -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' - elif 'GITEA_PUSHER_NAME' in osenv and 'GITEA_REPO_NAME' in osenv: - env = 'gitea' - 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 - - -GiteaEnvironment = build_environment_klass('gitea') -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 ' 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="=", 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:]) -- cgit v1.2.1