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