From cd5251522bc9f3e5bfa87cf73ae020662dc67f1d Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Sep 24 2018 08:25:50 +0000 Subject: Port multimail to new style runner Signed-off-by: Patrick Uiterwijk --- diff --git a/pagure/hooks/files/git_multimail.py b/pagure/hooks/files/git_multimail.py deleted file mode 100755 index 31e0c03..0000000 --- a/pagure/hooks/files/git_multimail.py +++ /dev/null @@ -1,2650 +0,0 @@ -#!/usr/bin/env python - -# flake8: noqa - -# 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. - -""" - -from __future__ import print_function, unicode_literals - -import sys -import os -import re -import bisect -import socket -import subprocess -import shlex -import optparse -import smtplib -import time - -import six - -try: - 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.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)" -) - -REFCHANGE_HEADER_TEMPLATE = """\ -Date: %(send_date)s -To: %(recipients)s -Subject: %(subject)s -MIME-Version: 1.0 -Content-Type: text/plain; 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 -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 "omits" are not gone; other references still -refer to them. Any revisions marked "discards" 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 "omits" are not gone; other references still -refer to them. Any revisions marked "discards" 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 "adds" were already present in the repository and have only -been added to this reference. - -""" - - -TAG_CREATED_TEMPLATE = """\ - at %(newrev_short)-9s (%(newrev_type)s) -""" - - -TAG_UPDATED_TEMPLATE = """\ -*** WARNING: tag %(short_refname)s was modified! *** - - from %(oldrev_short)-9s (%(oldrev_type)s) - to %(newrev_short)-9s (%(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)10s %(rev_short)-9s %(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 -Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s -MIME-Version: 1.0 -Content-Type: text/plain; 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 -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 -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. - -""" - - -REVISION_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 - else: - stdin = None - p = subprocess.Popen( - cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw - ) - (out, err) = p.communicate(input) - 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 header_encode(text, header_name=None): - """Encode and line-wrap the value of an email header field.""" - - try: - if isinstance(text, str): - text = text.decode(ENCODING, "replace") - return Header(text, header_name=header_name).encode() - except UnicodeEncodeError: - return Header( - text, header_name=header_name, charset=CHARSET, errors="replace" - ).encode() - - -def addr_header_encode(text, header_name=None): - """Encode and line-wrap the value of an email header field containing - email addresses.""" - - return Header( - ", ".join( - formataddr((header_encode(name), emailaddr)) - for name, emailaddr in getaddresses([text]) - ), - header_name=header_name, - ).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] - - 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 as e: - 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 get_recipients(self, name, default=None): - """Read a recipients list from the configuration. - - Return the result as a comma-separated list of email - addresses, or default if the option is unset. If the setting - has multiple values, concatenate them with comma separators.""" - - lines = self.get_all(name, default=None) - if lines is None: - return default - return ", ".join(line.strip() for line in lines) - - 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 has_key(self, name): - return self.get_all(name, default=None) is not None - - def unset_all(self, name): - try: - read_git_output( - ["config", "--unset-all", "%s.%s" % (self.section, name)], - env=self.env, - ) - except CommandError as e: - 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) - - -@six.python_2_unicode_compatible -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 __hash__(self): - return hash(self.sha1) - - def __bool__(self): - return bool(self.sha1) - - 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 - - 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.""" - - return self.environment.get_values() - - 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) - 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, **extra_values): - """Break template into lines and expand each line.""" - - values = self.get_values(**extra_values) - 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) - for line in template.splitlines(): - (name, value) = line.split(":", 1) - - try: - value = value % values - except KeyError as e: - if DEBUG: - sys.stderr.write( - "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_email_intro(self): - """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): - """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): - """Generate the footer of the email, a line at a time. - - The footer is always included, irrespective of - multimailhook.emailmaxlines.""" - - raise NotImplementedError() - - 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" - for line in self.generate_email_intro(): - yield line - - body = self.generate_email_body(push) - if body_filter is not None: - body = body_filter(body) - for line in body: - yield line - - for line in self.generate_email_footer(): - yield line - - -class Revision(Change): - """A Change consisting of a single git commit.""" - - 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) - - def _compute_values(self): - values = Change._compute_values(self) - - oneline = read_git_output( - ["log", "--format=%s", "--no-walk", self.rev.sha1] - ) - - values["rev"] = self.rev.sha1 - values["rev_short"] = self.rev.short - values["change_type"] = self.change_type - values["refname"] = self.refname - 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["num"] = self.num - values["tot"] = self.tot - values["recipients"] = self.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_email_intro(self): - for line in self.expand_lines(REVISION_INTRO_TEMPLATE): - yield line - - def generate_email_body(self, push): - """Show this revision.""" - - return read_git_lines( - ["log"] + self.environment.commitlogopts + ["-1", self.rev.sha1], - keepends=True, - ) - - def generate_email_footer(self): - return self.expand_lines(REVISION_FOOTER_TEMPLATE) - - -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: - sys.stderr.write( - "*** Push-update of tracking branch %r\n" - "*** - incomplete email generated.\n" % (refname,) - ) - klass = OtherReferenceChange - else: - # Some other reference namespace: - sys.stderr.write( - "*** Push-update of strange reference %r\n" - "*** - incomplete email generated.\n" % (refname,) - ) - klass = OtherReferenceChange - else: - # Anything else (is there anything else?) - sys.stderr.write( - "*** Unknown type of update to %r (%s)\n" - "*** - incomplete email generated.\n" % (refname, rev.type) - ) - klass = OtherReferenceChange - - return klass( - environment, - refname=refname, - short_refname=short_refname, - old=old, - new=new, - rev=rev, - ) - - 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.diffopts = environment.diffopts - self.logopts = environment.logopts - self.commitlogopts = environment.commitlogopts - self.showlog = environment.refchange_showlog - - 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["recipients"] = self.recipients - values["oldrev"] = "%s" % self.old - values["oldrev_short"] = self.old.short - values["newrev"] = "%s" % 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 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( - REFCHANGE_HEADER_TEMPLATE, **extra_values - ): - yield line - - def generate_email_intro(self): - for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE): - 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): - return self.expand_lines(FOOTER_TEMPLATE) - - 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_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.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): - yield line - for line in self.generate_revision_change_log( - [r.rev.sha1 for r in new_revisions] - ): - 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 = "discards" - else: - action = "omits" - 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 = "adds" - 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 = "discards" - else: - action = "omits" - 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 = "adds" - yield self.expand( - BRIEF_SUMMARY_TEMPLATE, - action=action, - rev_short=sha1, - text=subject, - ) - - yield "\n" - - if new_commits: - for line in self.expand_lines( - NEW_REVISIONS_TEMPLATE, tot=len(new_commits) - ): - yield line - for line in self.generate_revision_change_log( - new_commits_list - ): - yield line - else: - for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): - 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="discards", text=subject - ) - 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" - - -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) - - -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 " tagged 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 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, 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'.""" - - 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 as e: - sys.stderr.write( - "*** Cannot execute command: %s\n" % " ".join(self.command) - + "*** %s\n" % e - + '*** Try setting multimailhook.mailer to "smtp"\n' - "*** to send emails without using the sendmail command.\n" - ) - sys.exit(1) - try: - p.stdin.writelines(lines) - except: - sys.stderr.write( - "*** Error while generating commit email\n" - "*** - mail sending aborted.\n" - ) - p.terminate() - 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, envelopesender, smtpserver): - if not envelopesender: - sys.stderr.write( - "fatal: git_multimail: cannot use SMTPMailer without a " - "sender address.\n" - "please set either multimailhook.envelopeSender or " - "user.email\n" - ) - sys.exit(1) - self.envelopesender = envelopesender - self.smtpserver = smtpserver - try: - self.smtp = smtplib.SMTP(self.smtpserver) - except Exception as e: - sys.stderr.write( - "*** Error establishing SMTP connection to %s***\n" - % self.smtpserver - ) - sys.stderr.write("*** %s\n" % e) - sys.exit(1) - - def __del__(self): - self.smtp.quit() - - def send(self, lines, to_addrs): - try: - msg = "".join(lines) - # turn comma-separated list into Python list if needed. - if isinstance(to_addrs, six.string_types): - to_addrs = [ - email for (name, email) in getaddresses([to_addrs]) - ] - self.smtp.sendmail(self.envelopesender, to_addrs, msg) - except Exception as e: - sys.stderr.write("*** Error sending email***\n") - sys.stderr.write("*** %s\n" % e) - self.smtp.quit() - 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): - self.f = f - - def send(self, lines, to_addrs): - self.f.write(self.SEPARATOR) - self.f.writelines(lines) - self.f.write(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() - - Return the 'From' email address used in the email 'From:' - headers. (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. - - They should also define the following attributes: - - announce_show_shortlog (bool) - - True iff announce emails should include a shortlog. - - 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. - - 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. - - """ - - 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.maxcommitemails = 500 - self.diffopts = ["--stat", "--summary", "--find-copies-harder"] - self.logopts = [] - self.refchange_showlog = False - self.commitlogopts = ["-C", "--stat", "-p", "--cc"] - - self.COMPUTED_KEYS = [ - "administrator", - "charset", - "emailprefix", - "fromaddr", - "pusher", - "pusher_email", - "repo_path", - "repo_shortname", - "sender", - ] - - self._values = None - - 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_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 = {} - - 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 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 - - -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". - """ - - def __init__(self, config, **kw): - super(ConfigOptionsEnvironmentMixin, self).__init__( - config=config, **kw - ) - - self.announce_show_shortlog = config.get_bool( - "announceshortlog", default=self.announce_show_shortlog - ) - - self.refchange_showlog = config.get_bool( - "refchangeshowlog", default=self.refchange_showlog - ) - - maxcommitemails = config.get("maxcommitemails") - if maxcommitemails is not None: - try: - self.maxcommitemails = int(maxcommitemails) - except ValueError: - sys.stderr.write( - "*** 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) - - 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) - - reply_to = config.get("replyTo") - self.__reply_to_refchange = config.get( - "replyToRefchange", default=reply_to - ) - if ( - self.__reply_to_refchange is not None - and self.__reply_to_refchange.lower() == "author" - ): - raise ConfigurationException( - '"author" is not an allowed setting for replyToRefchange' - ) - self.__reply_to_commit = config.get("replyToCommit", default=reply_to) - - 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 and emailprefix.strip(): - return emailprefix.strip() + " " - else: - return "[%s] " % (self.get_repo_shortname(),) - - def get_sender(self): - return self.config.get("envelopesender") - - def get_fromaddr(self): - fromaddr = self.config.get("from") - if fromaddr: - return fromaddr - else: - config = Config("user") - fromname = config.get("name", default="Pagure") - fromemail = config.get("email", default="") - if fromemail: - return formataddr([fromname, fromemail]) - else: - return self.get_sender() - - def get_reply_to_refchange(self, refchange): - if self.__reply_to_refchange is None: - return super( - ConfigOptionsEnvironmentMixin, self - ).get_reply_to_refchange(refchange) - elif self.__reply_to_refchange.lower() == "pusher": - return self.get_pusher_email() - elif self.__reply_to_refchange.lower() == "none": - return None - else: - return self.__reply_to_refchange - - def get_reply_to_commit(self, revision): - if self.__reply_to_commit is None: - return super( - ConfigOptionsEnvironmentMixin, self - ).get_reply_to_commit(revision) - elif self.__reply_to_commit.lower() == "author": - return revision.get_author() - elif self.__reply_to_commit.lower() == "pusher": - return self.get_pusher_email() - elif self.__reply_to_commit.lower() == "none": - return None - else: - return self.__reply_to_commit - - -class FilterLinesEnvironmentMixin(Environment): - """Handle encoding and maximum line length of body lines. - - emailmaxlinelength (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, emailmaxlinelength=500, **kw): - super(FilterLinesEnvironmentMixin, self).__init__(**kw) - self.__strict_utf8 = strict_utf8 - self.__emailmaxlinelength = emailmaxlinelength - - def filter_body(self, lines): - lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines) - if self.__strict_utf8: - lines = (line.decode(ENCODING, "replace") for line in lines) - # Limit the line length in Unicode-space to avoid - # splitting characters: - if self.__emailmaxlinelength: - lines = limit_linelength(lines, self.__emailmaxlinelength) - lines = (line.encode(ENCODING, "replace") for line in lines) - elif self.__emailmaxlinelength: - lines = limit_linelength(lines, self.__emailmaxlinelength) - - return lines - - -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 - - emailmaxlinelength = config.get("emailmaxlinelength") - if emailmaxlinelength is not None: - kw["emailmaxlinelength"] = int(emailmaxlinelength) - - 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: - lines = limit_lines(lines, self.__emailmaxlines) - return lines - - -class ConfigMaxlinesEnvironmentMixin( - ConfigEnvironmentMixin, MaxlinesEnvironmentMixin -): - """Limit the email body to the number of lines specified in config.""" - - def __init__(self, config, **kw): - emailmaxlines = int(config.get("emailmaxlines", default="0")) - super(ConfigMaxlinesEnvironmentMixin, self).__init__( - config=config, emailmaxlines=emailmaxlines, **kw - ) - - -class FQDNEnvironmentMixin(Environment): - """A mixin that sets the host's FQDN to its constructor argument.""" - - def __init__(self, fqdn, **kw): - super(FQDNEnvironmentMixin, self).__init__(**kw) - self.COMPUTED_KEYS += ["fqdn"] - self.__fqdn = fqdn - - def get_fqdn(self): - """Return the fully-qualified domain name for this host. - - Return None if it is unavailable or unwanted.""" - - return self.__fqdn - - -class ConfigFQDNEnvironmentMixin(ConfigEnvironmentMixin, FQDNEnvironmentMixin): - """Read the FQDN from the config.""" - - def __init__(self, config, **kw): - fqdn = config.get("fqdn") - super(ConfigFQDNEnvironmentMixin, self).__init__( - config=config, fqdn=fqdn, **kw - ) - - -class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin): - """Get the FQDN by calling socket.getfqdn().""" - - def __init__(self, **kw): - super(ComputeFQDNEnvironmentMixin, self).__init__( - fqdn=socket.getfqdn(), **kw - ) - - -class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin): - """Deduce pusher_email from pusher by appending an emaildomain.""" - - def __init__(self, **kw): - super(PusherDomainEnvironmentMixin, self).__init__(**kw) - self.__emaildomain = self.config.get("emaildomain") - - def get_pusher_email(self): - if self.__emaildomain: - # Derive the pusher's full email address in the default way: - return "%s@%s" % (self.get_pusher(), self.__emaildomain) - else: - return super(PusherDomainEnvironmentMixin, self).get_pusher_email() - - -class StaticRecipientsEnvironmentMixin(Environment): - """Set recipients statically based on constructor parameters.""" - - def __init__( - self, - refchange_recipients, - announce_recipients, - revision_recipients, - **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: - if not ( - refchange_recipients or announce_recipients or revision_recipients - ): - raise ConfigurationException("No email recipients configured!") - self.__refchange_recipients = refchange_recipients - self.__announce_recipients = announce_recipients - self.__revision_recipients = revision_recipients - - def get_refchange_recipients(self, refchange): - return self.__refchange_recipients - - def get_announce_recipients(self, annotated_tag_change): - return self.__announce_recipients - - def get_revision_recipients(self, revision): - return self.__revision_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" - ), - **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: - retval = config.get_recipients(name) - if retval is not None: - return retval - else: - return "" - - -class ProjectdescEnvironmentMixin(Environment): - """Make a "projectdesc" value available for templates. - - By default, it is set to the first line of $GIT_DIR/description - (if that file is present and appears to be set meaningfully).""" - - def __init__(self, **kw): - super(ProjectdescEnvironmentMixin, self).__init__(**kw) - self.COMPUTED_KEYS += ["projectdesc"] - - def get_projectdesc(self): - """Return a one-line descripition of the project.""" - - git_dir = get_git_dir() - try: - with open(os.path.join(git_dir, "description")) as f: - projectdesc = f.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", "unknown user") - - -class GenericEnvironment( - ProjectdescEnvironmentMixin, - ConfigMaxlinesEnvironmentMixin, - ComputeFQDNEnvironmentMixin, - ConfigFilterLinesEnvironmentMixin, - ConfigRecipientsEnvironmentMixin, - PusherDomainEnvironmentMixin, - ConfigOptionsEnvironmentMixin, - GenericEnvironmentMixin, - Environment, -): - pass - - -class GitoliteEnvironmentMixin(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(GitoliteEnvironmentMixin, self).get_repo_shortname() - ) - - def get_pusher(self): - return self.osenv.get("GL_USER", "unknown user") - - -class IncrementalDateTime(six.Iterator): - """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() - - def __next__(self): - formatted = formatdate(self.time, True) - self.time += 1 - return formatted - - -class GitoliteEnvironment( - ProjectdescEnvironmentMixin, - ConfigMaxlinesEnvironmentMixin, - ComputeFQDNEnvironmentMixin, - ConfigFilterLinesEnvironmentMixin, - ConfigRecipientsEnvironmentMixin, - PusherDomainEnvironmentMixin, - ConfigOptionsEnvironmentMixin, - GitoliteEnvironmentMixin, - Environment, -): - pass - - -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 - Push._compute_other_ref_sha1s() by listing all references then - removing any affected by this push. - - 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, changes): - self.changes = sorted(changes, key=self._sort_key) - - # The SHA-1s of commits referred to by references unaffected - # by this push: - other_ref_sha1s = self._compute_other_ref_sha1s() - - self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec( - other_ref_sha1s.union( - change.old.sha1 - for change in self.changes - if change.old.type in ["commit", "tag"] - ) - ) - self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec( - other_ref_sha1s.union( - change.new.sha1 - for change in self.changes - if change.new.type in ["commit", "tag"] - ) - ) - - @classmethod - def _sort_key(klass, change): - return ( - klass.SORT_ORDER[change.__class__, change.change_type], - change.refname, - ) - - def _compute_other_ref_sha1s(self): - """Return the GitObjects referred to by references unaffected by - this push.""" - - # 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)" - ) - 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: - sha1s.add(sha1) - - return sha1s - - def _compute_rev_exclusion_spec(self, sha1s): - """Return an exclusion specification for 'git rev-list'. - - git_objects is an iterable over GitObject instances. Return a - string that can be passed to the standard input of 'git - rev-list --stdin' to exclude all of the commits referred to by - git_objects.""" - - return "".join(["^%s\n" % (sha1,) for sha1 in sorted(sha1s)]) - - 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.""" - - if not reference_change: - new_revs = sorted( - change.new.sha1 for change in self.changes if change.new - ) - elif not reference_change.new.commit_sha1: - return [] - else: - new_revs = [reference_change.new.commit_sha1] - - cmd = ["rev-list", "--stdin"] + new_revs - return read_git_lines(cmd, input=self._old_rev_exclusion_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.""" - - if not reference_change.old.commit_sha1: - return [] - else: - old_revs = [reference_change.old.commit_sha1] - - cmd = ["rev-list", "--stdin"] + old_revs - return read_git_lines(cmd, input=self._new_rev_exclusion_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: - # Check if we've got anyone to send to - if not change.recipients: - sys.stderr.write( - "*** no recipients configured so no email will be sent\n" - "*** for %r update %s->%s\n" - % (change.refname, change.old.sha1, change.new.sha1) - ) - else: - sys.stderr.write( - "Sending notification emails to: %s\n" - % (change.recipients,) - ) - extra_values = {"send_date": next(send_date)} - mailer.send( - change.generate_email(self, body_filter, extra_values), - change.recipients, - ) - - sha1s = [] - for sha1 in reversed(list(self.get_new_commits(change))): - if sha1 in unhandled_sha1s: - sha1s.append(sha1) - unhandled_sha1s.remove(sha1) - - max_emails = change.environment.maxcommitemails - if max_emails and len(sha1s) > max_emails: - sys.stderr.write( - "*** 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\n" - % max_emails - ) - return - - for (num, sha1) in enumerate(sha1s): - rev = Revision( - change, GitObject(sha1), num=num + 1, tot=len(sha1s) - ) - 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: - sys.stderr.write( - "ERROR: No emails were sent for the following new commits:\n" - " %s\n" % ("\n ".join(sorted(unhandled_sha1s)),) - ) - - -def run_as_post_receive_hook(environment, mailer): - changes = [] - for line in sys.stdin: - (oldrev, newrev, refname) = line.strip().split(" ", 2) - changes.append( - ReferenceChange.create(environment, oldrev, newrev, refname) - ) - push = Push(changes) - push.send_emails(mailer, body_filter=environment.filter_body) - - -def run_as_update_hook(environment, mailer, refname, oldrev, newrev): - changes = [ - ReferenceChange.create( - environment, - read_git_output(["rev-parse", "--verify", oldrev]), - read_git_output(["rev-parse", "--verify", newrev]), - refname, - ) - ] - push = Push(changes) - push.send_emails(mailer, body_filter=environment.filter_body) - - -def choose_mailer(config, environment): - mailer = config.get("mailer", default="sendmail") - - if mailer == "smtp": - smtpserver = config.get("smtpserver", default="localhost") - mailer = SMTPMailer( - envelopesender=( - environment.get_sender() or environment.get_fromaddr() - ), - smtpserver=smtpserver, - ) - elif mailer == "sendmail": - command = config.get("sendmailcommand") - if command: - command = shlex.split(command) - mailer = SendMailer( - command=command, envelopesender=environment.get_sender() - ) - else: - sys.stderr.write( - "fatal: multimailhook.mailer is set to an incorrect value: " - '"%s"\n' % mailer + 'please use one of "smtp" or "sendmail".\n' - ) - sys.exit(1) - return mailer - - -KNOWN_ENVIRONMENTS = { - "generic": GenericEnvironmentMixin, - "gitolite": GitoliteEnvironmentMixin, -} - - -def choose_environment(config, osenv=None, env=None, recipients=None): - if not osenv: - osenv = os.environ - - environment_mixins = [ - ProjectdescEnvironmentMixin, - ConfigMaxlinesEnvironmentMixin, - ComputeFQDNEnvironmentMixin, - ConfigFilterLinesEnvironmentMixin, - PusherDomainEnvironmentMixin, - ConfigOptionsEnvironmentMixin, - ] - environment_kw = {"osenv": osenv, "config": config} - - if not env: - env = config.get("environment") - - if not env: - if "GL_USER" in osenv and "GL_REPO" in osenv: - env = "gitolite" - else: - env = "generic" - - environment_mixins.append(KNOWN_ENVIRONMENTS[env]) - - if recipients: - environment_mixins.insert(0, StaticRecipientsEnvironmentMixin) - environment_kw["refchange_recipients"] = recipients - environment_kw["announce_recipients"] = recipients - environment_kw["revision_recipients"] = recipients - else: - environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin) - - environment_klass = type( - "EffectiveEnvironment", tuple(environment_mixins) + (Environment,), {} - ) - return environment_klass(**environment_kw) - - -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=["generic", "gitolite"], - 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)." - ), - ) - - (options, args) = parser.parse_args(args) - - config = Config("multimailhook") - - try: - environment = choose_environment( - config, - osenv=os.environ, - env=options.environment, - recipients=options.recipients, - ) - - if options.show_env: - sys.stderr.write("Environment values:\n") - for (k, v) in sorted(environment.get_values().items()): - sys.stderr.write(" %s : %r\n" % (k, v)) - sys.stderr.write("\n") - - if options.stdout: - mailer = OutputMailer(sys.stdout) - else: - mailer = choose_mailer(config, environment) - - # Dual mode: if arguments were specified on the command line, run - # like an update hook; otherwise, run as a post-receive hook. - if args: - if len(args) != 3: - parser.error("Need zero or three non-option arguments") - (refname, oldrev, newrev) = args - run_as_update_hook(environment, mailer, refname, oldrev, newrev) - else: - run_as_post_receive_hook(environment, mailer) - except ConfigurationException as e: - print(e, file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/pagure/hooks/files/git_multimail.py b/pagure/hooks/files/git_multimail.py new file mode 120000 index 0000000..6be2006 --- /dev/null +++ b/pagure/hooks/files/git_multimail.py @@ -0,0 +1 @@ +/does/not/exist \ No newline at end of file diff --git a/pagure/hooks/files/git_multimail_upstream.py b/pagure/hooks/files/git_multimail_upstream.py new file mode 100755 index 0000000..31e0c03 --- /dev/null +++ b/pagure/hooks/files/git_multimail_upstream.py @@ -0,0 +1,2650 @@ +#!/usr/bin/env python + +# flake8: noqa + +# 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. + +""" + +from __future__ import print_function, unicode_literals + +import sys +import os +import re +import bisect +import socket +import subprocess +import shlex +import optparse +import smtplib +import time + +import six + +try: + 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.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)" +) + +REFCHANGE_HEADER_TEMPLATE = """\ +Date: %(send_date)s +To: %(recipients)s +Subject: %(subject)s +MIME-Version: 1.0 +Content-Type: text/plain; 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 +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 "omits" are not gone; other references still +refer to them. Any revisions marked "discards" 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 "omits" are not gone; other references still +refer to them. Any revisions marked "discards" 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 "adds" were already present in the repository and have only +been added to this reference. + +""" + + +TAG_CREATED_TEMPLATE = """\ + at %(newrev_short)-9s (%(newrev_type)s) +""" + + +TAG_UPDATED_TEMPLATE = """\ +*** WARNING: tag %(short_refname)s was modified! *** + + from %(oldrev_short)-9s (%(oldrev_type)s) + to %(newrev_short)-9s (%(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)10s %(rev_short)-9s %(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 +Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s +MIME-Version: 1.0 +Content-Type: text/plain; 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 +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 +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. + +""" + + +REVISION_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 + else: + stdin = None + p = subprocess.Popen( + cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw + ) + (out, err) = p.communicate(input) + 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 header_encode(text, header_name=None): + """Encode and line-wrap the value of an email header field.""" + + try: + if isinstance(text, str): + text = text.decode(ENCODING, "replace") + return Header(text, header_name=header_name).encode() + except UnicodeEncodeError: + return Header( + text, header_name=header_name, charset=CHARSET, errors="replace" + ).encode() + + +def addr_header_encode(text, header_name=None): + """Encode and line-wrap the value of an email header field containing + email addresses.""" + + return Header( + ", ".join( + formataddr((header_encode(name), emailaddr)) + for name, emailaddr in getaddresses([text]) + ), + header_name=header_name, + ).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] + + 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 as e: + 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 get_recipients(self, name, default=None): + """Read a recipients list from the configuration. + + Return the result as a comma-separated list of email + addresses, or default if the option is unset. If the setting + has multiple values, concatenate them with comma separators.""" + + lines = self.get_all(name, default=None) + if lines is None: + return default + return ", ".join(line.strip() for line in lines) + + 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 has_key(self, name): + return self.get_all(name, default=None) is not None + + def unset_all(self, name): + try: + read_git_output( + ["config", "--unset-all", "%s.%s" % (self.section, name)], + env=self.env, + ) + except CommandError as e: + 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) + + +@six.python_2_unicode_compatible +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 __hash__(self): + return hash(self.sha1) + + def __bool__(self): + return bool(self.sha1) + + 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 + + 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.""" + + return self.environment.get_values() + + 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) + 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, **extra_values): + """Break template into lines and expand each line.""" + + values = self.get_values(**extra_values) + 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) + for line in template.splitlines(): + (name, value) = line.split(":", 1) + + try: + value = value % values + except KeyError as e: + if DEBUG: + sys.stderr.write( + "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_email_intro(self): + """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): + """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): + """Generate the footer of the email, a line at a time. + + The footer is always included, irrespective of + multimailhook.emailmaxlines.""" + + raise NotImplementedError() + + 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" + for line in self.generate_email_intro(): + yield line + + body = self.generate_email_body(push) + if body_filter is not None: + body = body_filter(body) + for line in body: + yield line + + for line in self.generate_email_footer(): + yield line + + +class Revision(Change): + """A Change consisting of a single git commit.""" + + 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) + + def _compute_values(self): + values = Change._compute_values(self) + + oneline = read_git_output( + ["log", "--format=%s", "--no-walk", self.rev.sha1] + ) + + values["rev"] = self.rev.sha1 + values["rev_short"] = self.rev.short + values["change_type"] = self.change_type + values["refname"] = self.refname + 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["num"] = self.num + values["tot"] = self.tot + values["recipients"] = self.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_email_intro(self): + for line in self.expand_lines(REVISION_INTRO_TEMPLATE): + yield line + + def generate_email_body(self, push): + """Show this revision.""" + + return read_git_lines( + ["log"] + self.environment.commitlogopts + ["-1", self.rev.sha1], + keepends=True, + ) + + def generate_email_footer(self): + return self.expand_lines(REVISION_FOOTER_TEMPLATE) + + +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: + sys.stderr.write( + "*** Push-update of tracking branch %r\n" + "*** - incomplete email generated.\n" % (refname,) + ) + klass = OtherReferenceChange + else: + # Some other reference namespace: + sys.stderr.write( + "*** Push-update of strange reference %r\n" + "*** - incomplete email generated.\n" % (refname,) + ) + klass = OtherReferenceChange + else: + # Anything else (is there anything else?) + sys.stderr.write( + "*** Unknown type of update to %r (%s)\n" + "*** - incomplete email generated.\n" % (refname, rev.type) + ) + klass = OtherReferenceChange + + return klass( + environment, + refname=refname, + short_refname=short_refname, + old=old, + new=new, + rev=rev, + ) + + 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.diffopts = environment.diffopts + self.logopts = environment.logopts + self.commitlogopts = environment.commitlogopts + self.showlog = environment.refchange_showlog + + 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["recipients"] = self.recipients + values["oldrev"] = "%s" % self.old + values["oldrev_short"] = self.old.short + values["newrev"] = "%s" % 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 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( + REFCHANGE_HEADER_TEMPLATE, **extra_values + ): + yield line + + def generate_email_intro(self): + for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE): + 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): + return self.expand_lines(FOOTER_TEMPLATE) + + 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_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.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): + yield line + for line in self.generate_revision_change_log( + [r.rev.sha1 for r in new_revisions] + ): + 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 = "discards" + else: + action = "omits" + 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 = "adds" + 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 = "discards" + else: + action = "omits" + 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 = "adds" + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, + action=action, + rev_short=sha1, + text=subject, + ) + + yield "\n" + + if new_commits: + for line in self.expand_lines( + NEW_REVISIONS_TEMPLATE, tot=len(new_commits) + ): + yield line + for line in self.generate_revision_change_log( + new_commits_list + ): + yield line + else: + for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): + 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="discards", text=subject + ) + 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" + + +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) + + +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 " tagged 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 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, 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'.""" + + 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 as e: + sys.stderr.write( + "*** Cannot execute command: %s\n" % " ".join(self.command) + + "*** %s\n" % e + + '*** Try setting multimailhook.mailer to "smtp"\n' + "*** to send emails without using the sendmail command.\n" + ) + sys.exit(1) + try: + p.stdin.writelines(lines) + except: + sys.stderr.write( + "*** Error while generating commit email\n" + "*** - mail sending aborted.\n" + ) + p.terminate() + 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, envelopesender, smtpserver): + if not envelopesender: + sys.stderr.write( + "fatal: git_multimail: cannot use SMTPMailer without a " + "sender address.\n" + "please set either multimailhook.envelopeSender or " + "user.email\n" + ) + sys.exit(1) + self.envelopesender = envelopesender + self.smtpserver = smtpserver + try: + self.smtp = smtplib.SMTP(self.smtpserver) + except Exception as e: + sys.stderr.write( + "*** Error establishing SMTP connection to %s***\n" + % self.smtpserver + ) + sys.stderr.write("*** %s\n" % e) + sys.exit(1) + + def __del__(self): + self.smtp.quit() + + def send(self, lines, to_addrs): + try: + msg = "".join(lines) + # turn comma-separated list into Python list if needed. + if isinstance(to_addrs, six.string_types): + to_addrs = [ + email for (name, email) in getaddresses([to_addrs]) + ] + self.smtp.sendmail(self.envelopesender, to_addrs, msg) + except Exception as e: + sys.stderr.write("*** Error sending email***\n") + sys.stderr.write("*** %s\n" % e) + self.smtp.quit() + 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): + self.f = f + + def send(self, lines, to_addrs): + self.f.write(self.SEPARATOR) + self.f.writelines(lines) + self.f.write(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() + + Return the 'From' email address used in the email 'From:' + headers. (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. + + They should also define the following attributes: + + announce_show_shortlog (bool) + + True iff announce emails should include a shortlog. + + 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. + + 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. + + """ + + 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.maxcommitemails = 500 + self.diffopts = ["--stat", "--summary", "--find-copies-harder"] + self.logopts = [] + self.refchange_showlog = False + self.commitlogopts = ["-C", "--stat", "-p", "--cc"] + + self.COMPUTED_KEYS = [ + "administrator", + "charset", + "emailprefix", + "fromaddr", + "pusher", + "pusher_email", + "repo_path", + "repo_shortname", + "sender", + ] + + self._values = None + + 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_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 = {} + + 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 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 + + +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". + """ + + def __init__(self, config, **kw): + super(ConfigOptionsEnvironmentMixin, self).__init__( + config=config, **kw + ) + + self.announce_show_shortlog = config.get_bool( + "announceshortlog", default=self.announce_show_shortlog + ) + + self.refchange_showlog = config.get_bool( + "refchangeshowlog", default=self.refchange_showlog + ) + + maxcommitemails = config.get("maxcommitemails") + if maxcommitemails is not None: + try: + self.maxcommitemails = int(maxcommitemails) + except ValueError: + sys.stderr.write( + "*** 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) + + 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) + + reply_to = config.get("replyTo") + self.__reply_to_refchange = config.get( + "replyToRefchange", default=reply_to + ) + if ( + self.__reply_to_refchange is not None + and self.__reply_to_refchange.lower() == "author" + ): + raise ConfigurationException( + '"author" is not an allowed setting for replyToRefchange' + ) + self.__reply_to_commit = config.get("replyToCommit", default=reply_to) + + 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 and emailprefix.strip(): + return emailprefix.strip() + " " + else: + return "[%s] " % (self.get_repo_shortname(),) + + def get_sender(self): + return self.config.get("envelopesender") + + def get_fromaddr(self): + fromaddr = self.config.get("from") + if fromaddr: + return fromaddr + else: + config = Config("user") + fromname = config.get("name", default="Pagure") + fromemail = config.get("email", default="") + if fromemail: + return formataddr([fromname, fromemail]) + else: + return self.get_sender() + + def get_reply_to_refchange(self, refchange): + if self.__reply_to_refchange is None: + return super( + ConfigOptionsEnvironmentMixin, self + ).get_reply_to_refchange(refchange) + elif self.__reply_to_refchange.lower() == "pusher": + return self.get_pusher_email() + elif self.__reply_to_refchange.lower() == "none": + return None + else: + return self.__reply_to_refchange + + def get_reply_to_commit(self, revision): + if self.__reply_to_commit is None: + return super( + ConfigOptionsEnvironmentMixin, self + ).get_reply_to_commit(revision) + elif self.__reply_to_commit.lower() == "author": + return revision.get_author() + elif self.__reply_to_commit.lower() == "pusher": + return self.get_pusher_email() + elif self.__reply_to_commit.lower() == "none": + return None + else: + return self.__reply_to_commit + + +class FilterLinesEnvironmentMixin(Environment): + """Handle encoding and maximum line length of body lines. + + emailmaxlinelength (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, emailmaxlinelength=500, **kw): + super(FilterLinesEnvironmentMixin, self).__init__(**kw) + self.__strict_utf8 = strict_utf8 + self.__emailmaxlinelength = emailmaxlinelength + + def filter_body(self, lines): + lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines) + if self.__strict_utf8: + lines = (line.decode(ENCODING, "replace") for line in lines) + # Limit the line length in Unicode-space to avoid + # splitting characters: + if self.__emailmaxlinelength: + lines = limit_linelength(lines, self.__emailmaxlinelength) + lines = (line.encode(ENCODING, "replace") for line in lines) + elif self.__emailmaxlinelength: + lines = limit_linelength(lines, self.__emailmaxlinelength) + + return lines + + +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 + + emailmaxlinelength = config.get("emailmaxlinelength") + if emailmaxlinelength is not None: + kw["emailmaxlinelength"] = int(emailmaxlinelength) + + 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: + lines = limit_lines(lines, self.__emailmaxlines) + return lines + + +class ConfigMaxlinesEnvironmentMixin( + ConfigEnvironmentMixin, MaxlinesEnvironmentMixin +): + """Limit the email body to the number of lines specified in config.""" + + def __init__(self, config, **kw): + emailmaxlines = int(config.get("emailmaxlines", default="0")) + super(ConfigMaxlinesEnvironmentMixin, self).__init__( + config=config, emailmaxlines=emailmaxlines, **kw + ) + + +class FQDNEnvironmentMixin(Environment): + """A mixin that sets the host's FQDN to its constructor argument.""" + + def __init__(self, fqdn, **kw): + super(FQDNEnvironmentMixin, self).__init__(**kw) + self.COMPUTED_KEYS += ["fqdn"] + self.__fqdn = fqdn + + def get_fqdn(self): + """Return the fully-qualified domain name for this host. + + Return None if it is unavailable or unwanted.""" + + return self.__fqdn + + +class ConfigFQDNEnvironmentMixin(ConfigEnvironmentMixin, FQDNEnvironmentMixin): + """Read the FQDN from the config.""" + + def __init__(self, config, **kw): + fqdn = config.get("fqdn") + super(ConfigFQDNEnvironmentMixin, self).__init__( + config=config, fqdn=fqdn, **kw + ) + + +class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin): + """Get the FQDN by calling socket.getfqdn().""" + + def __init__(self, **kw): + super(ComputeFQDNEnvironmentMixin, self).__init__( + fqdn=socket.getfqdn(), **kw + ) + + +class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin): + """Deduce pusher_email from pusher by appending an emaildomain.""" + + def __init__(self, **kw): + super(PusherDomainEnvironmentMixin, self).__init__(**kw) + self.__emaildomain = self.config.get("emaildomain") + + def get_pusher_email(self): + if self.__emaildomain: + # Derive the pusher's full email address in the default way: + return "%s@%s" % (self.get_pusher(), self.__emaildomain) + else: + return super(PusherDomainEnvironmentMixin, self).get_pusher_email() + + +class StaticRecipientsEnvironmentMixin(Environment): + """Set recipients statically based on constructor parameters.""" + + def __init__( + self, + refchange_recipients, + announce_recipients, + revision_recipients, + **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: + if not ( + refchange_recipients or announce_recipients or revision_recipients + ): + raise ConfigurationException("No email recipients configured!") + self.__refchange_recipients = refchange_recipients + self.__announce_recipients = announce_recipients + self.__revision_recipients = revision_recipients + + def get_refchange_recipients(self, refchange): + return self.__refchange_recipients + + def get_announce_recipients(self, annotated_tag_change): + return self.__announce_recipients + + def get_revision_recipients(self, revision): + return self.__revision_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" + ), + **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: + retval = config.get_recipients(name) + if retval is not None: + return retval + else: + return "" + + +class ProjectdescEnvironmentMixin(Environment): + """Make a "projectdesc" value available for templates. + + By default, it is set to the first line of $GIT_DIR/description + (if that file is present and appears to be set meaningfully).""" + + def __init__(self, **kw): + super(ProjectdescEnvironmentMixin, self).__init__(**kw) + self.COMPUTED_KEYS += ["projectdesc"] + + def get_projectdesc(self): + """Return a one-line descripition of the project.""" + + git_dir = get_git_dir() + try: + with open(os.path.join(git_dir, "description")) as f: + projectdesc = f.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", "unknown user") + + +class GenericEnvironment( + ProjectdescEnvironmentMixin, + ConfigMaxlinesEnvironmentMixin, + ComputeFQDNEnvironmentMixin, + ConfigFilterLinesEnvironmentMixin, + ConfigRecipientsEnvironmentMixin, + PusherDomainEnvironmentMixin, + ConfigOptionsEnvironmentMixin, + GenericEnvironmentMixin, + Environment, +): + pass + + +class GitoliteEnvironmentMixin(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(GitoliteEnvironmentMixin, self).get_repo_shortname() + ) + + def get_pusher(self): + return self.osenv.get("GL_USER", "unknown user") + + +class IncrementalDateTime(six.Iterator): + """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() + + def __next__(self): + formatted = formatdate(self.time, True) + self.time += 1 + return formatted + + +class GitoliteEnvironment( + ProjectdescEnvironmentMixin, + ConfigMaxlinesEnvironmentMixin, + ComputeFQDNEnvironmentMixin, + ConfigFilterLinesEnvironmentMixin, + ConfigRecipientsEnvironmentMixin, + PusherDomainEnvironmentMixin, + ConfigOptionsEnvironmentMixin, + GitoliteEnvironmentMixin, + Environment, +): + pass + + +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 + Push._compute_other_ref_sha1s() by listing all references then + removing any affected by this push. + + 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, changes): + self.changes = sorted(changes, key=self._sort_key) + + # The SHA-1s of commits referred to by references unaffected + # by this push: + other_ref_sha1s = self._compute_other_ref_sha1s() + + self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec( + other_ref_sha1s.union( + change.old.sha1 + for change in self.changes + if change.old.type in ["commit", "tag"] + ) + ) + self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec( + other_ref_sha1s.union( + change.new.sha1 + for change in self.changes + if change.new.type in ["commit", "tag"] + ) + ) + + @classmethod + def _sort_key(klass, change): + return ( + klass.SORT_ORDER[change.__class__, change.change_type], + change.refname, + ) + + def _compute_other_ref_sha1s(self): + """Return the GitObjects referred to by references unaffected by + this push.""" + + # 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)" + ) + 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: + sha1s.add(sha1) + + return sha1s + + def _compute_rev_exclusion_spec(self, sha1s): + """Return an exclusion specification for 'git rev-list'. + + git_objects is an iterable over GitObject instances. Return a + string that can be passed to the standard input of 'git + rev-list --stdin' to exclude all of the commits referred to by + git_objects.""" + + return "".join(["^%s\n" % (sha1,) for sha1 in sorted(sha1s)]) + + 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.""" + + if not reference_change: + new_revs = sorted( + change.new.sha1 for change in self.changes if change.new + ) + elif not reference_change.new.commit_sha1: + return [] + else: + new_revs = [reference_change.new.commit_sha1] + + cmd = ["rev-list", "--stdin"] + new_revs + return read_git_lines(cmd, input=self._old_rev_exclusion_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.""" + + if not reference_change.old.commit_sha1: + return [] + else: + old_revs = [reference_change.old.commit_sha1] + + cmd = ["rev-list", "--stdin"] + old_revs + return read_git_lines(cmd, input=self._new_rev_exclusion_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: + # Check if we've got anyone to send to + if not change.recipients: + sys.stderr.write( + "*** no recipients configured so no email will be sent\n" + "*** for %r update %s->%s\n" + % (change.refname, change.old.sha1, change.new.sha1) + ) + else: + sys.stderr.write( + "Sending notification emails to: %s\n" + % (change.recipients,) + ) + extra_values = {"send_date": next(send_date)} + mailer.send( + change.generate_email(self, body_filter, extra_values), + change.recipients, + ) + + sha1s = [] + for sha1 in reversed(list(self.get_new_commits(change))): + if sha1 in unhandled_sha1s: + sha1s.append(sha1) + unhandled_sha1s.remove(sha1) + + max_emails = change.environment.maxcommitemails + if max_emails and len(sha1s) > max_emails: + sys.stderr.write( + "*** 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\n" + % max_emails + ) + return + + for (num, sha1) in enumerate(sha1s): + rev = Revision( + change, GitObject(sha1), num=num + 1, tot=len(sha1s) + ) + 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: + sys.stderr.write( + "ERROR: No emails were sent for the following new commits:\n" + " %s\n" % ("\n ".join(sorted(unhandled_sha1s)),) + ) + + +def run_as_post_receive_hook(environment, mailer): + changes = [] + for line in sys.stdin: + (oldrev, newrev, refname) = line.strip().split(" ", 2) + changes.append( + ReferenceChange.create(environment, oldrev, newrev, refname) + ) + push = Push(changes) + push.send_emails(mailer, body_filter=environment.filter_body) + + +def run_as_update_hook(environment, mailer, refname, oldrev, newrev): + changes = [ + ReferenceChange.create( + environment, + read_git_output(["rev-parse", "--verify", oldrev]), + read_git_output(["rev-parse", "--verify", newrev]), + refname, + ) + ] + push = Push(changes) + push.send_emails(mailer, body_filter=environment.filter_body) + + +def choose_mailer(config, environment): + mailer = config.get("mailer", default="sendmail") + + if mailer == "smtp": + smtpserver = config.get("smtpserver", default="localhost") + mailer = SMTPMailer( + envelopesender=( + environment.get_sender() or environment.get_fromaddr() + ), + smtpserver=smtpserver, + ) + elif mailer == "sendmail": + command = config.get("sendmailcommand") + if command: + command = shlex.split(command) + mailer = SendMailer( + command=command, envelopesender=environment.get_sender() + ) + else: + sys.stderr.write( + "fatal: multimailhook.mailer is set to an incorrect value: " + '"%s"\n' % mailer + 'please use one of "smtp" or "sendmail".\n' + ) + sys.exit(1) + return mailer + + +KNOWN_ENVIRONMENTS = { + "generic": GenericEnvironmentMixin, + "gitolite": GitoliteEnvironmentMixin, +} + + +def choose_environment(config, osenv=None, env=None, recipients=None): + if not osenv: + osenv = os.environ + + environment_mixins = [ + ProjectdescEnvironmentMixin, + ConfigMaxlinesEnvironmentMixin, + ComputeFQDNEnvironmentMixin, + ConfigFilterLinesEnvironmentMixin, + PusherDomainEnvironmentMixin, + ConfigOptionsEnvironmentMixin, + ] + environment_kw = {"osenv": osenv, "config": config} + + if not env: + env = config.get("environment") + + if not env: + if "GL_USER" in osenv and "GL_REPO" in osenv: + env = "gitolite" + else: + env = "generic" + + environment_mixins.append(KNOWN_ENVIRONMENTS[env]) + + if recipients: + environment_mixins.insert(0, StaticRecipientsEnvironmentMixin) + environment_kw["refchange_recipients"] = recipients + environment_kw["announce_recipients"] = recipients + environment_kw["revision_recipients"] = recipients + else: + environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin) + + environment_klass = type( + "EffectiveEnvironment", tuple(environment_mixins) + (Environment,), {} + ) + return environment_klass(**environment_kw) + + +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=["generic", "gitolite"], + 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)." + ), + ) + + (options, args) = parser.parse_args(args) + + config = Config("multimailhook") + + try: + environment = choose_environment( + config, + osenv=os.environ, + env=options.environment, + recipients=options.recipients, + ) + + if options.show_env: + sys.stderr.write("Environment values:\n") + for (k, v) in sorted(environment.get_values().items()): + sys.stderr.write(" %s : %r\n" % (k, v)) + sys.stderr.write("\n") + + if options.stdout: + mailer = OutputMailer(sys.stdout) + else: + mailer = choose_mailer(config, environment) + + # Dual mode: if arguments were specified on the command line, run + # like an update hook; otherwise, run as a post-receive hook. + if args: + if len(args) != 3: + parser.error("Need zero or three non-option arguments") + (refname, oldrev, newrev) = args + run_as_update_hook(environment, mailer, refname, oldrev, newrev) + else: + run_as_post_receive_hook(environment, mailer) + except ConfigurationException as e: + print(e, file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/pagure/hooks/mail.py b/pagure/hooks/mail.py index 583f458..dde96bf 100644 --- a/pagure/hooks/mail.py +++ b/pagure/hooks/mail.py @@ -11,7 +11,9 @@ from __future__ import unicode_literals import sqlalchemy as sa +import os import pygit2 +import subprocess import wtforms try: @@ -21,9 +23,8 @@ except ImportError: from sqlalchemy.orm import relation from sqlalchemy.orm import backref -from pagure.hooks import BaseHook, RequiredIf +from pagure.hooks import BaseHook, BaseRunner, RequiredIf from pagure.lib.model import BASE, Project -from pagure.utils import get_repo_path class MailTable(BASE): @@ -65,6 +66,53 @@ class MailForm(FlaskForm): active = wtforms.BooleanField("Active", [wtforms.validators.Optional()]) +class MailRunner(BaseRunner): + @staticmethod + def post_receive(session, username, project, repotype, repodir, changes): + """ Run the multimail post-receive hook. + + For args, see BaseRunner.runhook. + """ + if repotype != "main": + # This hook is only useful on the main repo + return + + # This may run on a temporary clone, but that doesn't matter. + # We set these options every time again anyway + repo_obj = pygit2.Repository(repodir) + repo_obj.config.set_multivar( + "multimailhook.mailingList", "", project.mail_to + ) + repo_obj.config.set_multivar( + "multimailhook.environment", "", "gitolite" + ) + + # Now just run the .py file as a git hook + hook_file = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "files", + "git_multimail_upstream.py", + ) + stdin = ( + "\n".join( + [ + "%s %s %s" % (changes[refname] + (refname,)) + for refname in changes + ] + ) + + "\n" + ) + + proc = subprocess.Popen( + [hook_file], cwd=repodir, stdin=subprocess.PIPE + ) + proc.communicate(stdin) + ecode = proc.wait() + if ecode != 0: + print("git_multimail failed") + raise SystemExit(1) + + class Mail(BaseHook): """ Mail hooks. """ @@ -78,36 +126,4 @@ class Mail(BaseHook): db_object = MailTable backref = "mail_hook" form_fields = ["mail_to", "active"] - - @classmethod - def install(cls, project, dbobj): - """ Method called to install the hook for a project. - - :arg project: a ``pagure.model.Project`` object to which the hook - should be installed - - """ - repopaths = [get_repo_path(project)] - repo_obj = pygit2.Repository(repopaths[0]) - - # Configure the hook - repo_obj.config.set_multivar( - "multimailhook.mailingList", "", dbobj.mail_to - ) - repo_obj.config.set_multivar( - "multimailhook.environment", "", "gitolite" - ) - - # Install the hook itself - cls.base_install(repopaths, dbobj, "mail", "git_multimail.py") - - @classmethod - def remove(cls, project): - """ Method called to remove the hook of a project. - - :arg project: a ``pagure.model.Project`` object to which the hook - should be installed - - """ - repopaths = [get_repo_path(project)] - cls.base_remove(repopaths, "mail") + runner = MailRunner diff --git a/tests/test_pagure_flask_ui_plugins_mail.py b/tests/test_pagure_flask_ui_plugins_mail.py index 7e814fd..bd2c6d4 100644 --- a/tests/test_pagure_flask_ui_plugins_mail.py +++ b/tests/test_pagure_flask_ui_plugins_mail.py @@ -87,9 +87,6 @@ class PagureFlaskPluginMailtests(tests.SimplePagureTest): '', output_text) - self.assertFalse(os.path.exists(os.path.join( - self.path, 'repos', 'test.git', 'hooks', 'post-receive.mail'))) - # Missing the required mail_to data = {'csrf_token': csrf_token, 'active': 'y'} @@ -119,9 +116,6 @@ class PagureFlaskPluginMailtests(tests.SimplePagureTest): '', output_text) - self.assertFalse(os.path.exists(os.path.join( - self.path, 'repos', 'test.git', 'hooks', 'post-receive.mail'))) - # Activate hook data = { 'csrf_token': csrf_token, @@ -149,9 +143,6 @@ class PagureFlaskPluginMailtests(tests.SimplePagureTest): '', output_text) - self.assertTrue(os.path.exists(os.path.join( - self.path, 'repos', 'test.git', 'hooks', 'post-receive.mail'))) - # De-Activate hook data = {'csrf_token': csrf_token} output = self.app.post( @@ -174,9 +165,6 @@ class PagureFlaskPluginMailtests(tests.SimplePagureTest): '', output_text) - self.assertFalse(os.path.exists(os.path.join( - self.path, 'repos', 'test.git', 'hooks', 'post-receive.mail'))) - if __name__ == '__main__': unittest.main(verbosity=2)