diff --git a/roles/git/hooks/files/gnome-post-receive-email b/roles/git/hooks/files/gnome-post-receive-email
index 4c5cd619b6..0780563ff4 100644
--- a/roles/git/hooks/files/gnome-post-receive-email
+++ b/roles/git/hooks/files/gnome-post-receive-email
@@ -1,941 +1,4594 @@
-#!/usr/bin/python
+#!/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.
#
-# gnome-post-receive-email - Post receive email hook for the GNOME Git repository
+# This file is part of git-multimail.
#
-# Copyright (C) 2008 Owen Taylor
-# Copyright (C) 2009 Red Hat, Inc
+# 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 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.
-#
-# 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
+#
tag when using HTML format. + + Escape special HTML characters and addandtags around + the given lines if we should be generating HTML as indicated by + self._contains_html_diff being set to true. + """ + if self._contains_html_diff: + yield "\n" + + for line in lines: + yield cgi.escape(line) + + yield "\n" + else: + for line in lines: + yield line + + def generate_email(self, push, body_filter=None, extra_header_values={}): + """Generate an email describing this change. + + Iterate over the lines (including the header lines) of an + email describing this change. If body_filter is not None, + then use it to filter the lines that are intended for the + email body. + + The extra_header_values field is received as a dict and not as + **kwargs, to allow passing other keyword arguments in the + future (e.g. passing extra values to generate_email_intro()""" + + for line in self.generate_email_header(**extra_header_values): + yield line + yield "\n" + html_escape_val = ( + self.environment.html_in_intro and self._contains_html_diff + ) + intro = self.generate_email_intro(html_escape_val) + if not self.environment.html_in_intro: + intro = self._wrap_for_html(intro) + for line in intro: + yield line + + if self.environment.commitBrowseURL: + for line in self.generate_browse_link( + self.environment.commitBrowseURL + ): + yield line + + body = self.generate_email_body(push) + if body_filter is not None: + body = body_filter(body) + + diff_started = False + if self._contains_html_diff: + # "white-space: pre" is the default, but we need to + # specify it again in case the message is viewed in a + # webmail which wraps it in an element setting white-space + # to something else (Zimbra does this and sets + # white-space: pre-line). + yield '' + for line in body: + if self._contains_html_diff: + # This is very, very naive. It would be much better to really + # parse the diff, i.e. look at how many lines do we have in + # the hunk headers instead of blindly highlighting everything + # that looks like it might be part of a diff. + bgcolor = "" + fgcolor = "" + if line.startswith("--- a/"): + diff_started = True + bgcolor = "e0e0ff" + elif line.startswith("diff ") or line.startswith("index "): + diff_started = True + fgcolor = "808080" + elif diff_started: + if line.startswith("+++ "): + bgcolor = "e0e0ff" + elif line.startswith("@@"): + bgcolor = "e0e0e0" + elif line.startswith("+"): + bgcolor = "e0ffe0" + elif line.startswith("-"): + bgcolor = "ffe0e0" + elif line.startswith("commit "): + fgcolor = "808000" + elif line.startswith(" "): + fgcolor = "404040" + + # Chop the trailing LF, we don't want it inside. + line = 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 %s\n" % (style, line) + else: + line = line + "\n" + + yield line + if self._contains_html_diff: + yield "
" + html_escape_val = ( + self.environment.html_in_footer and self._contains_html_diff + ) + footer = self.generate_email_footer(html_escape_val) + if not self.environment.html_in_footer: + footer = self._wrap_for_html(footer) + for line in footer: + yield line + + def get_specific_fromaddr(self): + """For kinds of Changes which specify it, return the kind-specific + From address to use.""" + return None + + +class Revision(Change): + """A Change consisting of a single git commit.""" + + CC_RE = re.compile(r"^\s*C[Cc]:\s*(?P[^#]+@[^\s#]*)\s*(#.*)?$") + + def __init__(self, reference_change, rev, num, tot): + Change.__init__(self, reference_change.environment) + self.reference_change = reference_change + self.rev = rev + self.change_type = self.reference_change.change_type + self.refname = self.reference_change.refname + self.num = num + self.tot = tot + self.author = read_git_output( + ["log", "--no-walk", "--format=%aN <%aE>", self.rev.sha1] + ) + self.recipients = self.environment.get_revision_recipients(self) + + 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: + yield line + + def generate_email_footer(self, html_escape_val): + return self.expand_lines( + REVISION_FOOTER_TEMPLATE, html_escape_val=html_escape_val + ) + + def generate_email(self, push, body_filter=None, extra_header_values={}): + self._contains_diff() + return Change.generate_email( + self, push, body_filter, extra_header_values + ) + + def get_specific_fromaddr(self): + return self.environment.from_commit + + +class ReferenceChange(Change): + """A Change to a Git reference. + + An abstract class representing a create, update, or delete of a + Git reference. Derived classes handle specific types of reference + (e.g., tags vs. branches). These classes generate the main + reference change email summarizing the reference change and + whether it caused any any commits to be added or removed. + + ReferenceChange objects are usually created using the static + create() method, which has the logic to decide which derived class + to instantiate.""" + + REF_RE = re.compile(r"^refs\/(?P[^\/]+)\/(?P .*)$") + + @staticmethod + def create(environment, oldrev, newrev, refname): + """Return a ReferenceChange object representing the change. + + Return an object that represents the type of change that is being + made. oldrev and newrev should be SHA1s or ZEROS.""" + + old = GitObject(oldrev) + new = GitObject(newrev) + rev = new or old + + # The revision type tells us what type the commit is, combined with + # the location of the ref we can decide between + # - working branch + # - tracking branch + # - unannotated tag + # - annotated tag + m = ReferenceChange.REF_RE.match(refname) + if m: + area = m.group("area") + short_refname = m.group("shortname") + else: + area = "" + short_refname = refname + + if rev.type == "tag": + # Annotated tag: + klass = AnnotatedTagChange + elif rev.type == "commit": + if area == "tags": + # Non-annotated tag: + klass = NonAnnotatedTagChange + elif area == "heads": + # Branch: + klass = BranchChange + elif area == "remotes": + # Tracking branch: + environment.log_warning( + "*** Push-update of tracking branch %r\n" + "*** - incomplete email generated." % (refname,) + ) + klass = OtherReferenceChange + else: + # Some other reference namespace: + environment.log_warning( + "*** Push-update of strange reference %r\n" + "*** - incomplete email generated." % (refname,) + ) + klass = OtherReferenceChange + else: + # Anything else (is there anything else?) + environment.log_warning( + "*** Unknown type of update to %r (%s)\n" + "*** - incomplete email generated." % (refname, rev.type) + ) + klass = OtherReferenceChange + + return klass( + environment, + refname=refname, + short_refname=short_refname, + old=old, + new=new, + rev=rev, + ) + + 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. + + The extra_header_values field is received as a dict and not as + **kwargs, to allow passing other keyword arguments in the + future (e.g. passing extra values to generate_email_intro() + + This method is overridden in BranchChange.""" + + raise NotImplementedError + + def get_subject(self): + template = { + "create": REF_CREATED_SUBJECT_TEMPLATE, + "update": REF_UPDATED_SUBJECT_TEMPLATE, + "delete": REF_DELETED_SUBJECT_TEMPLATE, + }[self.change_type] + return self.expand(template) + + def generate_email_header(self, **extra_values): + if "subject" not in extra_values: + extra_values["subject"] = self.get_subject() + + for line in self.expand_header_lines( + self.header_template, **extra_values + ): + yield line + + def generate_email_intro(self, html_escape_val=False): + for line in self.expand_lines( + self.intro_template, html_escape_val=html_escape_val + ): + yield line + + def generate_email_body(self, push): + """Call the appropriate body-generation routine. + + Call one of generate_create_summary() / + generate_update_summary() / generate_delete_summary().""" + + change_summary = { + "create": self.generate_create_summary, + "delete": self.generate_delete_summary, + "update": self.generate_update_summary, + }[self.change_type](push) + for line in change_summary: + yield line + + for line in self.generate_revision_change_summary(push): + yield line + + def generate_email_footer(self, html_escape_val): + return self.expand_lines( + self.footer_template, html_escape_val=html_escape_val + ) + + def generate_revision_change_graph(self, push): + if self.showgraph: + args = ["--graph"] + self.graphopts + for newold in ("new", "old"): + has_newold = False + spec = push.get_commits_spec(newold, self) + for line in git_log(spec, args=args, keepends=True): + if not has_newold: + has_newold = True + yield "\n" + yield "Graph of %s commits:\n\n" % ( + {"new": "new", "old": "discarded"}[newold], + ) + yield " " + line + if has_newold: + yield "\n" + + def generate_revision_change_log(self, new_commits_list): + if self.showlog: + yield "\n" + yield "Detailed log of new commits:\n\n" + for line in read_git_lines( + ["log", "--no-walk"] + + self.logopts + + new_commits_list + + ["--"], + keepends=True, + ): + yield line + + def generate_new_revision_summary(self, tot, new_commits_list, push): + for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): + yield line + for line in self.generate_revision_change_graph(push): + yield line + for line in self.generate_revision_change_log(new_commits_list): + yield line + + def generate_revision_change_summary(self, push): + """Generate a summary of the revisions added/removed by this change.""" + + if self.new.commit_sha1 and not self.old.commit_sha1: + # A new reference was created. List the new revisions + # brought by the new reference (i.e., those revisions that + # were not in the repository before this reference + # change). + sha1s = list(push.get_new_commits(self)) + sha1s.reverse() + tot = len(sha1s) + new_revisions = [ + Revision(self, GitObject(sha1), num=i + 1, tot=tot) + for (i, sha1) in enumerate(sha1s) + ] + + if new_revisions: + yield self.expand( + "This %(refname_type)s includes the following new commits:\n" + ) + yield "\n" + for r in new_revisions: + (sha1, subject) = r.rev.get_summary() + yield r.expand( + BRIEF_SUMMARY_TEMPLATE, action="new", text=subject + ) + yield "\n" + for line in self.generate_new_revision_summary( + tot, [r.rev.sha1 for r in new_revisions], push + ): + yield line + else: + for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): + yield line + + elif self.new.commit_sha1 and self.old.commit_sha1: + # A reference was changed to point at a different commit. + # List the revisions that were removed and/or added *from + # that reference* by this reference change, along with a + # diff between the trees for its old and new values. + + # List of the revisions that were added to the branch by + # this update. Note this list can include revisions that + # have already had notification emails; we want such + # revisions in the summary even though we will not send + # new notification emails for them. + adds = list( + generate_summaries( + "--topo-order", + "--reverse", + "%s..%s" % (self.old.commit_sha1, self.new.commit_sha1), + ) + ) + + # List of the revisions that were removed from the branch + # by this update. This will be empty except for + # non-fast-forward updates. + discards = list( + generate_summaries( + "%s..%s" % (self.new.commit_sha1, self.old.commit_sha1) + ) + ) + + if adds: + new_commits_list = push.get_new_commits(self) + else: + new_commits_list = [] + new_commits = CommitSet(new_commits_list) + + if discards: + discarded_commits = CommitSet(push.get_discarded_commits(self)) + else: + discarded_commits = CommitSet([]) + + if discards and adds: + for (sha1, subject) in discards: + if sha1 in discarded_commits: + action = "discard" + else: + action = "omit" + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, + action=action, + rev_short=sha1, + text=subject, + ) + for (sha1, subject) in adds: + if sha1 in new_commits: + action = "new" + else: + action = "add" + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, + action=action, + rev_short=sha1, + text=subject, + ) + yield "\n" + for line in self.expand_lines(NON_FF_TEMPLATE): + yield line + + elif discards: + for (sha1, subject) in discards: + if sha1 in discarded_commits: + action = "discard" + else: + action = "omit" + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, + action=action, + rev_short=sha1, + text=subject, + ) + yield "\n" + for line in self.expand_lines(REWIND_ONLY_TEMPLATE): + yield line + + elif adds: + (sha1, subject) = self.old.get_summary() + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, + action="from", + rev_short=sha1, + text=subject, + ) + for (sha1, subject) in adds: + if sha1 in new_commits: + action = "new" + else: + action = "add" + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, + action=action, + rev_short=sha1, + text=subject, + ) + + yield "\n" + + if new_commits: + for line in self.generate_new_revision_summary( + len(new_commits), new_commits_list, push + ): + yield line + else: + for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): + yield line + for line in self.generate_revision_change_graph(push): + yield line + + # The diffstat is shown from the old revision to the new + # revision. This is to show the truth of what happened in + # this change. There's no point showing the stat from the + # base to the new revision because the base is effectively a + # random revision at this point - the user will be interested + # in what this revision changed - including the undoing of + # previous revisions in the case of non-fast-forward updates. + yield "\n" + yield "Summary of changes:\n" + for line in read_git_lines( + ["diff-tree"] + + self.diffopts + + ["%s..%s" % (self.old.commit_sha1, self.new.commit_sha1)], + keepends=True, + ): + yield line + + elif self.old.commit_sha1 and not self.new.commit_sha1: + # A reference was deleted. List the revisions that were + # removed from the repository by this reference change. + + sha1s = list(push.get_discarded_commits(self)) + tot = len(sha1s) + discarded_revisions = [ + Revision(self, GitObject(sha1), num=i + 1, tot=tot) + for (i, sha1) in enumerate(sha1s) + ] + + if discarded_revisions: + for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE): + yield line + yield "\n" + for r in discarded_revisions: + (sha1, subject) = r.rev.get_summary() + yield r.expand( + BRIEF_SUMMARY_TEMPLATE, action="discard", text=subject + ) + for line in self.generate_revision_change_graph(push): + yield line + else: + for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE): + yield line + + elif not self.old.commit_sha1 and not self.new.commit_sha1: + for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE): + yield line + + def generate_create_summary(self, push): + """Called for the creation of a reference.""" + + # This is a new reference and so oldrev is not valid + (sha1, subject) = self.new.get_summary() + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action="at", rev_short=sha1, text=subject + ) + yield "\n" + + def generate_update_summary(self, push): + """Called for the change of a pre-existing branch.""" + + return iter([]) + + def generate_delete_summary(self, push): + """Called for the deletion of any type of reference.""" + + (sha1, subject) = self.old.get_summary() + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action="was", rev_short=sha1, text=subject + ) + yield "\n" + + def get_specific_fromaddr(self): + return self.environment.from_refchange + + +class BranchChange(ReferenceChange): + refname_type = "branch" + + def __init__(self, environment, refname, short_refname, old, new, rev): + ReferenceChange.__init__( + self, + environment, + refname=refname, + short_refname=short_refname, + old=old, + new=new, + rev=rev, + ) + self.recipients = environment.get_refchange_recipients(self) + self._single_revision = None + + def send_single_combined_email(self, known_added_sha1s): + if not self.environment.combine_when_single_commit: + return None + + # In the sadly-all-too-frequent usecase of people pushing only + # one of their commits at a time to a repository, users feel + # the reference change summary emails are noise rather than + # important signal. This is because, in this particular + # usecase, there is a reference change summary email for each + # new commit, and all these summaries do is point out that + # there is one new commit (which can readily be inferred by + # the existence of the individual revision email that is also + # sent). In such cases, our users prefer there to be a combined + # reference change summary/new revision email. + # + # So, if the change is an update and it doesn't discard any + # commits, and it adds exactly one non-merge commit (gerrit + # forces a workflow where every commit is individually merged + # and the git-multimail hook fired off for just this one + # change), then we send a combined refchange/revision email. + try: + # If this change is a reference update that doesn't discard + # any commits... + if self.change_type != "update": + return None + + if read_git_lines( + ["merge-base", self.old.sha1, self.new.sha1] + ) != [self.old.sha1]: + return None + + # Check if this update introduced exactly one non-merge + # commit: + + def split_line(line): + """Split line into (sha1, [parent,...]).""" + + words = line.split() + return (words[0], words[1:]) + + # Get the new commits introduced by the push as a list of + # (sha1, [parent,...]) + new_commits = [ + split_line(line) + for line in read_git_lines( + [ + "log", + "-3", + "--format=%H %P", + "%s..%s" % (self.old.sha1, self.new.sha1), + ] + ) + ] + + if not new_commits: + return None + + # If the newest commit is a merge, save it for a later check + # but otherwise ignore it + merge = None + tot = len(new_commits) + if len(new_commits[0][1]) > 1: + merge = new_commits[0][0] + del new_commits[0] + + # Our primary check: we can't combine if more than one commit + # is introduced. We also currently only combine if the new + # commit is a non-merge commit, though it may make sense to + # combine if it is a merge as well. + if not ( + len(new_commits) == 1 + and len(new_commits[0][1]) == 1 + and new_commits[0][0] in known_added_sha1s + ): + return None + + # We do not want to combine revision and refchange emails if + # those go to separate locations. + rev = Revision(self, GitObject(new_commits[0][0]), 1, tot) + if rev.recipients != self.recipients: + return None + + # We ignored the newest commit if it was just a merge of the one + # commit being introduced. But we don't want to ignore that + # merge commit it it involved conflict resolutions. Check that. + if merge and merge != read_git_output( + ["diff-tree", "--cc", merge] + ): + return None + + # We can combine the refchange and one new revision emails + # into one. Return the Revision that a combined email should + # be sent about. + return rev + except CommandError: + # Cannot determine number of commits in old..new or new..old; + # don't combine reference/revision emails: + return None + + def generate_combined_email( + self, push, revision, body_filter=None, extra_header_values={} + ): + values = revision.get_values() + if extra_header_values: + values.update(extra_header_values) + if "subject" not in extra_header_values: + values["subject"] = self.expand( + COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values + ) + + self._single_revision = revision + self._contains_diff() + self.header_template = COMBINED_HEADER_TEMPLATE + self.intro_template = COMBINED_INTRO_TEMPLATE + self.footer_template = COMBINED_FOOTER_TEMPLATE + + def revision_gen_link(base_url): + # revision is used only to generate the body, and + # _content_type is set while generating headers. Get it + # from the BranchChange object. + revision._content_type = self._content_type + return revision.generate_browse_link(base_url) + + self.generate_browse_link = revision_gen_link + for line in self.generate_email(push, body_filter, values): + yield line + + def generate_email_body(self, push): + """Call the appropriate body generation routine. + + If this is a combined refchange/revision email, the special logic + for handling this combined email comes from this function. For + other cases, we just use the normal handling.""" + + # If self._single_revision isn't set; don't override + if not self._single_revision: + for line in super(BranchChange, self).generate_email_body(push): + yield line return - extra = self.get_project_extra() - if extra: - extra = "/" + extra + # 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: - extra = "" - subject = "[" + projectshort + extra + "] " + self.get_subject() + prevtag = None + yield " length %s bytes\n" % ( + read_git_output(["cat-file", "-s", tagobject]), + ) - email_out = start_email() + yield " by %s\n" % (tagger,) + yield " on %s\n" % (tagged,) + yield "\n" - self.generate_header(email_out, subject, include_revs=True, oldrev=self.oldrev, newrev=self.newrev) - self.generate_body(email_out) + # 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 - end_email() + if self.show_shortlog and tagtype == "commit": + # Only commit tags make sense to have rev-list operations + # performed on them + yield "\n" + if prevtag: + # Show changes since the previous release + revlist = read_git_output( + [ + "rev-list", + "--pretty=short", + "%s..%s" % (prevtag, self.new), + ], + keepends=True, + ) + else: + # No previous tag, show all the changes since time + # began + revlist = read_git_output( + ["rev-list", "--pretty=short", "%s" % (self.new,)], + keepends=True, + ) + for line in read_git_lines( + ["shortlog"], input=revlist, keepends=True + ): + yield line - # Allow multiple emails to be sent - used for branch updates - def send_extra_emails(self): + yield LOGEND + yield "\n" + + def generate_create_summary(self, push): + """Called for the creation of an annotated tag.""" + + for line in self.expand_lines(TAG_CREATED_TEMPLATE): + yield line + + for line in self.describe_tag(push): + yield line + + def generate_update_summary(self, push): + """Called for the update of an annotated tag. + + This is probably a rare event and may not even be allowed.""" + + for line in self.expand_lines(TAG_UPDATED_TEMPLATE): + yield line + + for line in self.describe_tag(push): + yield line + + def generate_delete_summary(self, push): + """Called when a non-annotated reference is updated.""" + + for line in self.expand_lines(TAG_DELETED_TEMPLATE): + yield line + + yield self.expand(" tag was %(oldrev_short)s\n") + yield "\n" + + +class NonAnnotatedTagChange(ReferenceChange): + refname_type = "tag" + + def __init__(self, environment, refname, short_refname, old, new, rev): + ReferenceChange.__init__( + self, + environment, + refname=refname, + short_refname=short_refname, + old=old, + new=new, + rev=rev, + ) + self.recipients = environment.get_refchange_recipients(self) + + def generate_create_summary(self, push): + """Called for the creation of an annotated tag.""" + + for line in self.expand_lines(TAG_CREATED_TEMPLATE): + yield line + + def generate_update_summary(self, push): + """Called when a non-annotated reference is updated.""" + + for line in self.expand_lines(TAG_UPDATED_TEMPLATE): + yield line + + def generate_delete_summary(self, push): + """Called when a non-annotated reference is updated.""" + + for line in self.expand_lines(TAG_DELETED_TEMPLATE): + yield line + + for line in ReferenceChange.generate_delete_summary(self, push): + yield line + + +class OtherReferenceChange(ReferenceChange): + refname_type = "reference" + + def __init__(self, environment, refname, short_refname, old, new, rev): + # We use the full refname as short_refname, because otherwise + # the full name of the reference would not be obvious from the + # text of the email. + ReferenceChange.__init__( + self, + environment, + refname=refname, + short_refname=refname, + old=old, + new=new, + rev=rev, + ) + self.recipients = environment.get_refchange_recipients(self) + + +class Mailer(object): + """An object that can send emails.""" + + def __init__(self, environment): + self.environment = environment + + def send(self, lines, to_addrs): + """Send an email consisting of lines. + + lines must be an iterable over the lines constituting the + header and body of the email. to_addrs is a list of recipient + addresses (can be needed even if lines already contains a + "To:" field). It can be either a string (comma-separated list + of email addresses) or a Python list of individual email + addresses. + + """ + + raise NotImplementedError() + + +class SendMailer(Mailer): + """Send emails using 'sendmail -oi -t'.""" + + SENDMAIL_CANDIDATES = ["/usr/sbin/sendmail", "/usr/lib/sendmail"] + + @staticmethod + def find_sendmail(): + for path in SendMailer.SENDMAIL_CANDIDATES: + if os.access(path, os.X_OK): + return path + else: + raise ConfigurationException( + "No sendmail executable found. " + "Try setting multimailhook.sendmailCommand." + ) + + def __init__(self, environment, command=None, envelopesender=None): + """Construct a SendMailer instance. + + command should be the command and arguments used to invoke + sendmail, as a list of strings. If an envelopesender is + provided, it will also be passed to the command, via '-f + envelopesender'.""" + super(SendMailer, self).__init__(environment) + if command: + self.command = command[:] + else: + self.command = [self.find_sendmail(), "-oi", "-t"] + + if envelopesender: + self.command.extend(["-f", envelopesender]) + + def send(self, lines, to_addrs): + try: + p = subprocess.Popen(self.command, stdin=subprocess.PIPE) + except OSError: + self.environment.get_logger().error( + "*** Cannot execute command: %s\n" % " ".join(self.command) + + "*** %s\n" % sys.exc_info()[1] + + '*** Try setting multimailhook.mailer to "smtp"\n' + + "*** to send emails without using the sendmail command.\n" + ) + sys.exit(1) + try: + lines = (str_to_bytes(line) for line in lines) + p.stdin.writelines(lines) + except Exception: + self.environment.get_logger().error( + "*** Error while generating commit email\n" + "*** - mail sending aborted.\n" + ) + if hasattr(p, "terminate"): + # subprocess.terminate() is not available in Python 2.4 + p.terminate() + else: + import signal + + os.kill(p.pid, signal.SIGTERM) + raise + else: + p.stdin.close() + retcode = p.wait() + if retcode: + raise CommandError(self.command, retcode) + + +class SMTPMailer(Mailer): + """Send emails using Python's smtplib.""" + + def __init__( + self, + environment, + envelopesender, + smtpserver, + smtpservertimeout=10.0, + smtpserverdebuglevel=0, + smtpencryption="none", + smtpuser="", + smtppass="", + smtpcacerts="", + ): + super(SMTPMailer, self).__init__(environment) + if not envelopesender: + self.environment.get_logger().error( + "fatal: git_multimail: cannot use SMTPMailer without a sender address.\n" + "please set either multimailhook.envelopeSender or user.email\n" + ) + sys.exit(1) + if smtpencryption == "ssl" and not (smtpuser and smtppass): + raise ConfigurationException( + "Cannot use SMTPMailer with security option ssl " + "without options username and password." + ) + self.envelopesender = envelopesender + self.smtpserver = smtpserver + self.smtpservertimeout = smtpservertimeout + self.smtpserverdebuglevel = smtpserverdebuglevel + self.security = smtpencryption + self.username = smtpuser + self.password = smtppass + self.smtpcacerts = smtpcacerts + try: + + def call(klass, server, timeout): + try: + return klass(server, timeout=timeout) + except TypeError: + # Old Python versions do not have timeout= argument. + return klass(server) + + if self.security == "none": + self.smtp = call( + smtplib.SMTP, + self.smtpserver, + timeout=self.smtpservertimeout, + ) + elif self.security == "ssl": + if self.smtpcacerts: + raise smtplib.SMTPException( + "Checking certificate is not supported for ssl, prefer starttls" + ) + self.smtp = call( + smtplib.SMTP_SSL, + self.smtpserver, + timeout=self.smtpservertimeout, + ) + elif self.security == "tls": + if "ssl" not in sys.modules: + self.environment.get_logger().error( + "*** Your Python version does not have the ssl library installed\n" + "*** smtpEncryption=tls is not available.\n" + "*** Either upgrade Python to 2.6 or later\n" + " or use git_multimail.py version 1.2.\n" + ) + if ":" not in self.smtpserver: + self.smtpserver += ":587" # default port for TLS + self.smtp = call( + smtplib.SMTP, + self.smtpserver, + timeout=self.smtpservertimeout, + ) + # start: ehlo + starttls + # equivalent to + # self.smtp.ehlo() + # self.smtp.starttls() + # with acces to the ssl layer + self.smtp.ehlo() + if not self.smtp.has_extn("starttls"): + raise smtplib.SMTPException( + "STARTTLS extension not supported by server" + ) + resp, reply = self.smtp.docmd("STARTTLS") + if resp != 220: + raise smtplib.SMTPException( + "Wrong answer to the STARTTLS command" + ) + if self.smtpcacerts: + self.smtp.sock = ssl.wrap_socket( + self.smtp.sock, + ca_certs=self.smtpcacerts, + cert_reqs=ssl.CERT_REQUIRED, + ) + else: + self.smtp.sock = ssl.wrap_socket( + self.smtp.sock, cert_reqs=ssl.CERT_NONE + ) + self.environment.get_logger().error( + "*** Warning, the server certificat is not verified (smtp) ***\n" + "*** set the option smtpCACerts ***\n" + ) + if not hasattr(self.smtp.sock, "read"): + # using httplib.FakeSocket with Python 2.5.x or earlier + self.smtp.sock.read = self.smtp.sock.recv + self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock) + self.smtp.helo_resp = None + self.smtp.ehlo_resp = None + self.smtp.esmtp_features = {} + self.smtp.does_esmtp = 0 + # end: ehlo + starttls + self.smtp.ehlo() + else: + sys.stdout.write( + "*** Error: Control reached an invalid option. ***" + ) + sys.exit(1) + if self.smtpserverdebuglevel > 0: + sys.stdout.write( + "*** Setting debug on for SMTP server connection (%s) ***\n" + % self.smtpserverdebuglevel + ) + self.smtp.set_debuglevel(self.smtpserverdebuglevel) + except Exception: + self.environment.get_logger().error( + "*** Error establishing SMTP connection to %s ***\n" + "*** %s\n" % (self.smtpserver, sys.exc_info()[1]) + ) + sys.exit(1) + + def __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.""" + + try: + return read_git_output(["rev-parse", "--git-dir"]) + except CommandError: + sys.stderr.write("fatal: git_multimail: not in a git directory\n") + sys.exit(1) + + +class Environment(object): + """Describes the environment in which the push is occurring. + + An Environment object encapsulates information about the local + environment. For example, it knows how to determine: + + * the name of the repository to which the push occurred + + * what user did the push + + * what users want to be informed about various types of changes. + + An Environment object is expected to have the following methods: + + get_repo_shortname() + + Return a short name for the repository, for display + purposes. + + get_repo_path() + + Return the absolute path to the Git repository. + + get_emailprefix() + + Return a string that will be prefixed to every email's + subject. + + get_pusher() + + Return the username of the person who pushed the changes. + This value is used in the email body to indicate who + pushed the change. + + get_pusher_email() (may return None) + + Return the email address of the person who pushed the + changes. The value should be a single RFC 2822 email + address as a string; e.g., "Joe User " + if available, otherwise "user@example.com". If set, the + value is used as the Reply-To address for refchange + emails. If it is impossible to determine the pusher's + email, this attribute should be set to None (in which case + no Reply-To header will be output). + + get_sender() + + Return the address to be used as the 'From' email address + in the email envelope. + + get_fromaddr(change=None) + + Return the 'From' email address used in the email 'From:' + headers. If the change is known when this function is + called, it is passed in as the 'change' parameter. (May + be a full RFC 2822 email address like 'Joe User + '.) + + get_administrator() + + Return the name and/or email of the repository + administrator. This value is used in the footer as the + person to whom requests to be removed from the + notification list should be sent. Ideally, it should + include a valid email address. + + get_reply_to_refchange() + get_reply_to_commit() + + Return the address to use in the email "Reply-To" header, + as a string. These can be an RFC 2822 email address, or + None to omit the "Reply-To" header. + get_reply_to_refchange() is used for refchange emails; + get_reply_to_commit() is used for individual commit + emails. + + get_ref_filter_regex() + + Return a tuple -- a compiled regex, and a boolean indicating + whether the regex picks refs to include (if False, the regex + matches on refs to exclude). + + get_default_ref_ignore_regex() + + Return a regex that should be ignored for both what emails + to send and when computing what commits are considered new + to the repository. Default is "^refs/notes/". + + get_max_subject_length() + + Return an int giving the maximal length for the subject + (git log --oneline). + + They should also define the following attributes: + + announce_show_shortlog (bool) + + True iff announce emails should include a shortlog. + + commit_email_format (string) + + If "html", generate commit emails in HTML instead of plain text + used by default. + + html_in_intro (bool) + html_in_footer (bool) + + When generating HTML emails, the introduction (respectively, + the footer) will be HTML-escaped iff html_in_intro (respectively, + the footer) is true. When false, only the values used to expand + the template are escaped. + + refchange_showgraph (bool) + + True iff refchanges emails should include a detailed graph. + + refchange_showlog (bool) + + True iff refchanges emails should include a detailed log. + + diffopts (list of strings) + + The options that should be passed to 'git diff' for the + summary email. The value should be a list of strings + representing words to be passed to the command. + + graphopts (list of strings) + + Analogous to diffopts, but contains options passed to + 'git log --graph' when generating the detailed graph for + a set of commits (see refchange_showgraph) + + logopts (list of strings) + + Analogous to diffopts, but contains options passed to + 'git log' when generating the detailed log for a set of + commits (see refchange_showlog) + + commitlogopts (list of strings) + + The options that should be passed to 'git log' for each + commit mail. The value should be a list of strings + representing words to be passed to the command. + + date_substitute (string) + + String to be used in substitution for 'Date:' at start of + line in the output of 'git log'. + + quiet (bool) + On success do not write to stderr + + stdout (bool) + Write email to stdout rather than emailing. Useful for debugging + + combine_when_single_commit (bool) + + True if a combined email should be produced when a single + new commit is pushed to a branch, False otherwise. + + from_refchange, from_commit (strings) + + Addresses to use for the From: field for refchange emails + and commit emails respectively. Set from + multimailhook.fromRefchange and multimailhook.fromCommit + by ConfigEnvironmentMixin. + + log_file, error_log_file, debug_log_file (string) + + Name of a file to which logs should be sent. + + verbose (int) + + How verbose the system should be. + - 0 (default): show info, errors, ... + - 1 : show basic debug info + """ + + REPO_NAME_RE = re.compile(r"^(?P .+?)(?:\.git)$") + + def __init__(self, osenv=None): + self.osenv = osenv or os.environ + self.announce_show_shortlog = False + self.commit_email_format = "text" + self.html_in_intro = False + self.html_in_footer = False + self.commitBrowseURL = None + self.maxcommitemails = 500 + self.diffopts = ["--stat", "--summary", "--find-copies-harder"] + self.graphopts = ["--oneline", "--decorate"] + self.logopts = [] + self.refchange_showgraph = False + self.refchange_showlog = False + self.commitlogopts = ["-C", "--stat", "-p", "--cc"] + self.date_substitute = "AuthorDate: " + self.quiet = False + self.stdout = False + self.combine_when_single_commit = True + self.logger = None + + self.COMPUTED_KEYS = [ + "administrator", + "charset", + "emailprefix", + "pusher", + "pusher_email", + "repo_path", + "repo_shortname", + "sender", + ] + + self._values = None + + def get_logger(self): + """Get (possibly creates) the logger associated to this environment.""" + if self.logger is None: + self.logger = Logger(self) + return self.logger + + def get_repo_shortname(self): + """Use the last part of the repo path, with ".git" stripped off if present.""" + + basename = os.path.basename(os.path.abspath(self.get_repo_path())) + m = self.REPO_NAME_RE.match(basename) + if m: + return m.group("name") + else: + return basename + + def get_pusher(self): + raise NotImplementedError() + + def get_pusher_email(self): + return None + + def get_fromaddr(self, change=None): + config = Config("user") + fromname = config.get("name", default="") + fromemail = config.get("email", default="") + if fromemail: + return formataddr([fromname, fromemail]) + return self.get_sender() + + def get_administrator(self): + return "the administrator of this repository" + + def get_emailprefix(self): + return "" + + def get_repo_path(self): + if read_git_output(["rev-parse", "--is-bare-repository"]) == "true": + path = get_git_dir() + else: + path = read_git_output(["rev-parse", "--show-toplevel"]) + return os.path.abspath(path) + + def get_charset(self): + return CHARSET + + def get_values(self): + """Return a dictionary {keyword: expansion} for this Environment. + + This method is called by Change._compute_values(). The keys + in the returned dictionary are available to be used in any of + the templates. The dictionary is created by calling + self.get_NAME() for each of the attributes named in + COMPUTED_KEYS and recording those that do not return None. + The return value is always a new dictionary.""" + + if self._values is None: + values = {"": ""} # %()s expands to the empty string. + + for key in self.COMPUTED_KEYS: + value = getattr(self, "get_%s" % (key,))() + if value is not None: + values[key] = value + + self._values = values + + return self._values.copy() + + def get_refchange_recipients(self, refchange): + """Return the recipients for notifications about refchange. + + Return the list of email addresses to which notifications + about the specified ReferenceChange should be sent.""" + + raise NotImplementedError() + + def get_announce_recipients(self, annotated_tag_change): + """Return the recipients for notifications about annotated_tag_change. + + Return the list of email addresses to which notifications + about the specified AnnotatedTagChange should be sent.""" + + raise NotImplementedError() + + def get_reply_to_refchange(self, refchange): + return self.get_pusher_email() + + def get_revision_recipients(self, revision): + """Return the recipients for messages about revision. + + Return the list of email addresses to which notifications + about the specified Revision should be sent. This method + could be overridden, for example, to take into account the + contents of the revision when deciding whom to notify about + it. For example, there could be a scheme for users to express + interest in particular files or subdirectories, and only + receive notification emails for revisions that affecting those + files.""" + + raise NotImplementedError() + + def get_reply_to_commit(self, revision): + return revision.author + + def get_default_ref_ignore_regex(self): + # The commit messages of git notes are essentially meaningless + # and "filenames" in git notes commits are an implementational + # detail that might surprise users at first. As such, we + # would need a completely different method for handling emails + # of git notes in order for them to be of benefit for users, + # which we simply do not have right now. + return "^refs/notes/" + + def get_max_subject_length(self): + """Return the maximal subject line (git log --oneline) length. + Longer subject lines will be truncated.""" + raise NotImplementedError() + + def filter_body(self, lines): + """Filter the lines intended for an email body. + + lines is an iterable over the lines that would go into the + email body. Filter it (e.g., limit the number of lines, the + line length, character set, etc.), returning another iterable. + See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin + for classes implementing this functionality.""" + + return lines + + def log_msg(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + self.get_logger().info(msg) + + def log_warning(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + self.get_logger().warning(msg) + + def log_error(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + self.get_logger().error(msg) + + def check(self): pass - def send_emails(self): - self.send_main_email() - self.send_extra_emails() -# ======================== +class ConfigEnvironmentMixin(Environment): + """A mixin that sets self.config to its constructor's config argument. -# Common baseclass for BranchCreation and BranchUpdate (but not BranchDeletion) -class BranchChange(RefChange): - def __init__(self, *args): - RefChange.__init__(self, *args) + This class's constructor consumes the "config" argument. - 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. + 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.""" - # 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 ] + def __init__(self, config, **kw): + super(ConfigEnvironmentMixin, self).__init__(**kw) + self.config = config - 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) + +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: - # Exclude commits that are ancestors of all other branches - detailed_commit_args.append("^" + branch) + self.commit_email_format = commit_email_format - detailed_commits = git.rev_list(*detailed_commit_args).splitlines() + html_in_intro = config.get_bool("htmlInIntro") + if html_in_intro is not None: + self.html_in_intro = html_in_intro - self.detailed_commits = set() - for id in detailed_commits: - self.detailed_commits.add(id) + html_in_footer = config.get_bool("htmlInFooter") + if html_in_footer is not None: + self.html_in_footer = html_in_footer - # 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] + "^" - 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() - else: - self.added_commits = [] - self.removed_commits = [] + 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.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.verbose = 0 - # 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 get_administrator(self): + return ( + self.config.get("administrator") + or self.get_sender() + or super(ConfigOptionsEnvironmentMixin, self).get_administrator() + ) - have_merge_commits = False - for commit in self.added_commits: - if commit_is_merge(commit): - have_merge_commits = True + def get_repo_shortname(self): + return ( + self.config.get("reponame") + or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname() + ) - 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)) + 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_needs_main_email(self): - return self.needs_cover_email + def get_sender(self): + return self.config.get("envelopesender") - # 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) + def process_addr(self, addr, change): + if addr.lower() == "author": + if hasattr(change, "author"): + return change.author + else: + return None + elif addr.lower() == "pusher": + return self.get_pusher_email() + elif addr.lower() == "none": + return None + else: + return addr + + def get_fromaddr(self, change=None): + fromaddr = self.config.get("from") + if change: + specific_fromaddr = change.get_specific_fromaddr() + if specific_fromaddr: + fromaddr = specific_fromaddr + if fromaddr: + fromaddr = self.process_addr(fromaddr, change) + if fromaddr: + return fromaddr + return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change) + + def get_reply_to_refchange(self, refchange): + if self.__reply_to_refchange is None: + return super( + ConfigOptionsEnvironmentMixin, self + ).get_reply_to_refchange(refchange) + else: + return self.process_addr(self.__reply_to_refchange, refchange) + + def get_reply_to_commit(self, revision): + if self.__reply_to_commit is None: + return super( + ConfigOptionsEnvironmentMixin, self + ).get_reply_to_commit(revision) + else: + return self.process_addr(self.__reply_to_commit, revision) + + def get_scancommitforcc(self): + return self.config.get("scancommitforcc") + + +class FilterLinesEnvironmentMixin(Environment): + """Handle encoding and maximum line length of body lines. + + email_max_line_length (int or None) + + The maximum length of any single line in the email body. + Longer lines are truncated at that length with ' [...]' + appended. + + strict_utf8 (bool) + + If this field is set to True, then the email body text is + expected to be UTF-8. Any invalid characters are + converted to U+FFFD, the Unicode replacement character + (encoded as UTF-8, of course). + + """ + + def __init__( + self, + strict_utf8=True, + email_max_line_length=500, + max_subject_length=500, + **kw + ): + super(FilterLinesEnvironmentMixin, self).__init__(**kw) + self.__strict_utf8 = strict_utf8 + self.__email_max_line_length = email_max_line_length + self.__max_subject_length = max_subject_length + + def filter_body(self, lines): + lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines) + if self.__strict_utf8: + if not PYTHON3: + lines = (line.decode(ENCODING, "replace") for line in lines) + # Limit the line length in Unicode-space to avoid + # splitting characters: + if self.__email_max_line_length > 0: + lines = limit_linelength(lines, self.__email_max_line_length) + if not PYTHON3: + lines = (line.encode(ENCODING, "replace") for line in lines) + elif self.__email_max_line_length: + lines = limit_linelength(lines, self.__email_max_line_length) + + return lines + + def get_max_subject_length(self): + return self.__max_subject_length + + +class ConfigFilterLinesEnvironmentMixin( + ConfigEnvironmentMixin, FilterLinesEnvironmentMixin +): + """Handle encoding and maximum line length based on config.""" + + def __init__(self, config, **kw): + strict_utf8 = config.get_bool("emailstrictutf8", default=None) + if strict_utf8 is not None: + kw["strict_utf8"] = strict_utf8 + + email_max_line_length = config.get("emailmaxlinelength") + if email_max_line_length is not None: + kw["email_max_line_length"] = int(email_max_line_length) + + max_subject_length = config.get( + "subjectMaxLength", default=email_max_line_length + ) + if max_subject_length is not None: + kw["max_subject_length"] = int(max_subject_length) + + super(ConfigFilterLinesEnvironmentMixin, self).__init__( + config=config, **kw + ) + + +class MaxlinesEnvironmentMixin(Environment): + """Limit the email body to a specified number of lines.""" + + def __init__(self, emailmaxlines, **kw): + super(MaxlinesEnvironmentMixin, self).__init__(**kw) + self.__emailmaxlines = emailmaxlines + + def filter_body(self, lines): + lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines) + if self.__emailmaxlines > 0: + lines = limit_lines(lines, self.__emailmaxlines) + return lines + + +class ConfigMaxlinesEnvironmentMixin( + ConfigEnvironmentMixin, MaxlinesEnvironmentMixin +): + """Limit the email body to the number of lines specified in config.""" + + def __init__(self, config, **kw): + emailmaxlines = int(config.get("emailmaxlines", default="0")) + super(ConfigMaxlinesEnvironmentMixin, self).__init__( + config=config, emailmaxlines=emailmaxlines, **kw + ) + + +class FQDNEnvironmentMixin(Environment): + """A mixin that sets the host's FQDN to its constructor argument.""" + + def __init__(self, fqdn, **kw): + super(FQDNEnvironmentMixin, self).__init__(**kw) + self.COMPUTED_KEYS += ["fqdn"] + self.__fqdn = fqdn + + def get_fqdn(self): + """Return the fully-qualified domain name for this host. + + Return None if it is unavailable or unwanted.""" + + return self.__fqdn + + +class ConfigFQDNEnvironmentMixin(ConfigEnvironmentMixin, FQDNEnvironmentMixin): + """Read the FQDN from the config.""" + + def __init__(self, config, **kw): + fqdn = config.get("fqdn") + super(ConfigFQDNEnvironmentMixin, self).__init__( + config=config, fqdn=fqdn, **kw + ) + + +class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin): + """Get the FQDN by calling socket.getfqdn().""" + + def __init__(self, **kw): + super(ComputeFQDNEnvironmentMixin, self).__init__( + fqdn=socket.getfqdn(), **kw + ) + + +class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin): + """Deduce pusher_email from pusher by appending an emaildomain.""" + + def __init__(self, **kw): + super(PusherDomainEnvironmentMixin, self).__init__(**kw) + self.__emaildomain = self.config.get("emaildomain") + + def get_pusher_email(self): + if self.__emaildomain: + # Derive the pusher's full email address in the default way: + return "%s@%s" % (self.get_pusher(), self.__emaildomain) + else: + return super(PusherDomainEnvironmentMixin, self).get_pusher_email() + + +class StaticRecipientsEnvironmentMixin(Environment): + """Set recipients statically based on constructor parameters.""" + + def __init__( + self, + refchange_recipients, + announce_recipients, + revision_recipients, + scancommitforcc, + **kw + ): + super(StaticRecipientsEnvironmentMixin, self).__init__(**kw) + + # The recipients for various types of notification emails, as + # RFC 2822 email addresses separated by commas (or the empty + # string if no recipients are configured). Although there is + # a mechanism to choose the recipient lists based on on the + # actual *contents* of the change being reported, we only + # choose based on the *type* of the change. Therefore we can + # compute them once and for all: + self.__refchange_recipients = refchange_recipients + self.__announce_recipients = announce_recipients + self.__revision_recipients = revision_recipients + + def check(self): + if not ( + self.get_refchange_recipients(None) + or self.get_announce_recipients(None) + or self.get_revision_recipients(None) + or self.get_scancommitforcc() + ): + raise ConfigurationException("No email recipients configured!") + super(StaticRecipientsEnvironmentMixin, self).check() + + def get_refchange_recipients(self, refchange): + if self.__refchange_recipients is None: + return super( + StaticRecipientsEnvironmentMixin, self + ).get_refchange_recipients(refchange) + return self.__refchange_recipients + + def get_announce_recipients(self, annotated_tag_change): + if self.__announce_recipients is None: + return super( + StaticRecipientsEnvironmentMixin, self + ).get_refchange_recipients(annotated_tag_change) + return self.__announce_recipients + + def get_revision_recipients(self, revision): + if self.__revision_recipients is None: + return super( + StaticRecipientsEnvironmentMixin, self + ).get_refchange_recipients(revision) + return self.__revision_recipients + + +class CLIRecipientsEnvironmentMixin(Environment): + """Mixin storing recipients information 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 "" - # 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 - else: - detail = "" - print >>out, " %s%s" % (to_bytes(commit_oneline(commit)), to_bytes(detail)) - if detail_note: - print >>out - print >>out, "(*) This commit already existed in another branch; no separate mail sent" +class StaticRefFilterEnvironmentMixin(Environment): + """Set branch filter statically based on constructor parameters.""" - def send_extra_emails(self): - total = len(self.added_commits) + 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) - for i, commit in enumerate(self.added_commits): - if not commit.id in self.detailed_commits: - continue - - email_out = start_email() - - if self.short_refname == 'master': - branch = "" - 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 - } - 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) - 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 - - def generate_body(self, out): - if len(self.added_commits) > 0: - print >>out, s(""" -The branch '%(short_refname)s' was created. - -Summary of new commits: - -""") % { - 'short_refname': to_bytes(self.short_refname), - } - - self.generate_commit_summary(out, self.added_commits) + 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: - print >>out, s(""" -The branch '%(short_refname)s' was created pointing to: - - %(commit_oneline)s - -""") % { - 'short_refname': to_bytes(self.short_refname), - 'commit_oneline': to_bytes(commit_oneline(self.newrev)) - } - -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 - return None - else: - if self.short_refname == 'master': - # Not saying 'master' all over the place reduces clutter - return None - else: - return self.short_refname - - 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] - - def generate_body_normal(self, out): - print >>out, s(""" -Summary of changes: - -""") - - self.generate_commit_summary(out, self.added_commits) - - 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: - - http://live.gnome.org/Git/Help/NonFastForward - -Commits removed from the branch: - -""") % { - 'short_refname': to_bytes(self.short_refname), - } - - self.generate_commit_summary(out, self.removed_commits, show_details=False) - - print >>out, s(""" - -Commits added to the branch: - -""") - 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 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 " - 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]) - - # Outputs information about the new tag - def generate_tag_info(self, out): - - print >>out, s(""" -Tagger: %(tagger)s -Date: %(date)s - -%(message)s - -""") % { - 'tagger': to_bytes(self.tagger), - 'date': to_bytes(self.date), - 'message': to_bytes(self.message), - } - - # 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 + ref_filter_regex = default_exclude 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 + 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 - 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) - } - else: - revision_range = self.newrev - print >>out, s(""" -Changes: - -""") - out.write(to_bytes(git.shortlog(revision_range))) - out.write("\n") - - def get_tag_type(self): - if self.have_signature: - return 'signed tag' - else: - return 'unsigned tag' - -class AnnotatedTagCreation(AnnotatedTagChange): - def get_subject(self): - return "Created tag " + self.short_refname - - def generate_body(self, out): - print >>out, s(""" -The %(tag_type)s '%(short_refname)s' was created. - -""") % { - '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 - - def generate_body(self, out): - print >>out, s(""" -The %(tag_type)s '%(short_refname)s' was deleted. It previously pointed to: - - %(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)), - } - -class AnnotatedTagUpdate(AnnotatedTagChange): - def get_subject(self): - return "Updated tag " + self.short_refname - - def generate_body(self, out): - print >>out, s(""" -The tag '%(short_refname)s' was replaced with a new tag. It previously -pointed to: - - %(old_commit_oneline)s - -NOTE: People pulling from the repository will not get the new tag. -For more information, please see: - - http://live.gnome.org/Git/Help/TagUpdates - -New tag information: - -""") % { - 'short_refname': to_bytes(self.short_refname), - 'old_commit_oneline': to_bytes(commit_oneline(self.old_commit_id)), - } - self.generate_tag_info(out) - -# ======================== - -class LightweightTagCreation(RefChange): - def get_subject(self): - return "Created tag " + self.short_refname - - def generate_body(self, out): - print >>out, s(""" -The lightweight tag '%(short_refname)s' was created pointing to: - - %(commit_oneline)s -""") % { - 'short_refname': to_bytes(self.short_refname), - 'commit_oneline': to_bytes(commit_oneline(self.newrev)) - } - -class LightweightTagDeletion(RefChange): - def get_subject(self): - return "Deleted tag " + self.short_refname - - def generate_body(self, out): - print >>out, s(""" -The lighweight tag '%(short_refname)s' was deleted. It previously pointed to: - - %(commit_oneline)s -""") % { - 'short_refname': to_bytes(self.short_refname), - 'commit_oneline': to_bytes(commit_oneline(self.oldrev)), - } - -class LightweightTagUpdate(RefChange): - def get_subject(self): - return "Updated tag " + self.short_refname - - def generate_body(self, out): - print >>out, s(""" -The lightweight tag '%(short_refname)s' was updated to point to: - - %(commit_oneline)s - -It previously pointed to: - - %(old_commit_oneline)s - -NOTE: People pulling from the repository will not get the new tag. -For more information, please see: - - 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)), - } - -# ======================== - -class InvalidRefDeletion(RefChange): - def get_subject(self): - return "Deleted invalid ref " + self.refname - - def generate_body(self, out): - print >>out, s(""" -The ref '%(refname)s' was deleted. It previously pointed nowhere. -""") % { - 'refname': to_bytes(self.refname), - } - -# ======================== - -class MiscChange(RefChange): - def __init__(self, refname, oldrev, newrev, message): - RefChange.__init__(self, refname, oldrev, newrev) - self.message = message - -class MiscCreation(MiscChange): - def get_subject(self): - return "Unexpected: Created " + self.refname - - def generate_body(self, out): - print >>out, s(""" -The ref '%(refname)s' was created pointing to: - - %(newrev)s - -This is unexpected because: - - %(message)s -""") % { - 'refname': to_bytes(self.refname), - 'newrev': to_bytes(self.newrev), - 'message': to_bytes(self.message), - } - -class MiscDeletion(MiscChange): - def get_subject(self): - return "Unexpected: Deleted " + self.refname - - def generate_body(self, out): - print >>out, s(""" -The ref '%(refname)s' was deleted. It previously pointed to: - - %(oldrev)s - -This is unexpected because: - - %(message)s -""") % { - 'refname': to_bytes(self.refname), - 'oldrev': to_bytes(self.oldrev), - 'message': to_bytes(self.message), - } - -class MiscUpdate(MiscChange): - def get_subject(self): - return "Unexpected: Updated " + self.refname - - def generate_body(self, out): - print >>out, s(""" -The ref '%(refname)s' was updated from: - - %(newrev)s - -To: - - %(oldrev)s - -This is unexpected because: - - %(message)s -""") % { - 'refname': to_bytes(self.refname), - 'oldrev': to_bytes(self.oldrev), - 'newrev': to_bytes(self.newrev), - 'message': to_bytes(self.message), - } - -# ======================== - -def make_change(oldrev, newrev, refname): - refname = refname - - # Canonicalize - oldrev = git.rev_parse(oldrev) - newrev = git.rev_parse(newrev) - - # Replacing the null revision with None makes it easier for us to test - # in subsequent code - - if re.match(r'^0+$', oldrev): - oldrev = None - else: - oldrev = oldrev - - 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) - - object_type = git.cat_file(target, t=True) - - # And then create the right type of change object - - # Closing the arguments like this simplifies the following code - def make(cls, *args): - return cls(refname, oldrev, newrev, *args) - - def make_misc_change(message): - if change_type == CREATE: - return make(MiscCreation, message) - elif change_type == DELETE: - return make(MiscDeletion, message) - else: - return make(MiscUpdate, message) - - 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) + 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(LightweightTagUpdate) - elif object_type == 'tag': - if change_type == CREATE: - return make(AnnotatedTagCreation) - elif change_type == DELETE: - return make(AnnotatedTagDeletion) - else: - return make(AnnotatedTagUpdate) + # Submitter has no configured email, it's just his name. + return self.__submitter 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) - else: - return make(BranchUpdate) + # 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 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) + return super( + GerritEnvironmentHighPrecMixin, self + ).get_pusher_email() -def main(): - global projectshort - global projectdesc - global user_fullname - global recipients - global maildomain - global mailshortdiff + 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/" - # 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')): + def get_revision_recipients(self, revision): + # Merge commits created by Gerrit when users hit "Submit this patchset" + # in the Web UI (or do equivalently with REST APIs or the gerrit review + # command) are not something users want to see an individual email for. + # Filter them out. + committer = read_git_output( + ["log", "--no-walk", "--format=%cN", revision.rev.sha1] + ) + if committer == "Gerrit Code Review": + return [] + else: + return super( + GerritEnvironmentHighPrecMixin, self + ).get_revision_recipients(revision) + + def get_update_method(self): + return self.__update_method + + +class GerritEnvironmentLowPrecMixin(Environment): + def __init__(self, project=None, submitter=None, **kw): + super(GerritEnvironmentLowPrecMixin, self).__init__(**kw) + self.__project = project + self.__submitter = submitter + + def get_repo_shortname(self): + return self.__project + + def get_fromaddr(self, change=None): + if self.__submitter and self.__submitter.find("<") != -1: + return self.__submitter + else: + return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr( + change + ) + + +class Push(object): + """Represent an entire push (i.e., a group of ReferenceChanges). + + It is easy to figure out what commits were added to a *branch* by + a Reference change: + + git rev-list change.old..change.new + + or removed from a *branch*: + + git rev-list change.new..change.old + + But it is not quite so trivial to determine which entirely new + commits were added to the *repository* by a push and which old + commits were discarded by a push. A big part of the job of this + class is to figure out these things, and to make sure that new + commits are only detailed once even if they were added to multiple + references. + + The first step is to determine the "other" references--those + unaffected by the current push. They are computed by listing all + references then removing any affected by this push. The results + are stored in Push._other_ref_sha1s. + + The commits contained in the repository before this push were + + git rev-list other1 other2 other3 ... change1.old change2.old ... + + Where "changeN.old" is the old value of one of the references + affected by this push. + + The commits contained in the repository after this push are + + git rev-list other1 other2 other3 ... change1.new change2.new ... + + The commits added by this push are the difference between these + two sets, which can be written + + git rev-list \ + ^other1 ^other2 ... \ + ^change1.old ^change2.old ... \ + change1.new change2.new ... + + The commits removed by this push can be computed by + + git rev-list \ + ^other1 ^other2 ... \ + ^change1.new ^change2.new ... \ + change1.old change2.old ... + + The last point is that it is possible that other pushes are + occurring simultaneously to this one, so reference values can + change at any time. It is impossible to eliminate all race + conditions, but we reduce the window of time during which problems + can occur by translating reference names to SHA1s as soon as + possible and working with SHA1s thereafter (because SHA1s are + immutable).""" + + # A map {(changeclass, changetype): integer} specifying the order + # that reference changes will be processed if multiple reference + # changes are included in a single push. The order is significant + # mostly because new commit notifications are threaded together + # with the first reference change that includes the commit. The + # following order thus causes commits to be grouped with branch + # changes (as opposed to tag changes) if possible. + SORT_ORDER = dict( + (value, i) + for (i, value) in enumerate( + [ + (BranchChange, "update"), + (BranchChange, "create"), + (AnnotatedTagChange, "update"), + (AnnotatedTagChange, "create"), + (NonAnnotatedTagChange, "update"), + (NonAnnotatedTagChange, "create"), + (BranchChange, "delete"), + (AnnotatedTagChange, "delete"), + (NonAnnotatedTagChange, "delete"), + (OtherReferenceChange, "update"), + (OtherReferenceChange, "create"), + (OtherReferenceChange, "delete"), + ] + ) + ) + + def __init__(self, environment, changes, ignore_other_refs=False): + self.changes = sorted(changes, key=self._sort_key) + self.__other_ref_sha1s = None + self.__cached_commits_spec = {} + self.environment = environment + + if ignore_other_refs: + self.__other_ref_sha1s = set() + + @classmethod + def _sort_key(klass, change): + return ( + klass.SORT_ORDER[change.__class__, change.change_type], + change.refname, + ) + + @property + def _other_ref_sha1s(self): + """The GitObjects referred to by references unaffected by this push. + """ + if self.__other_ref_sha1s is None: + # The refnames being changed by this push: + updated_refs = set(change.refname for change in self.changes) + + # The SHA-1s of commits referred to by all references in this + # repository *except* updated_refs: + sha1s = set() + fmt = ( + "%(objectname) %(objecttype) %(refname)\n" + "%(*objectname) %(*objecttype) %(refname)" + ) + ( + ref_filter_regex, + is_inclusion_filter, + ) = self.environment.get_ref_filter_regex() + for line in read_git_lines( + ["for-each-ref", "--format=%s" % (fmt,)] + ): + (sha1, type, name) = line.split(" ", 2) + if ( + sha1 + and type == "commit" + and name not in updated_refs + and include_ref( + name, ref_filter_regex, is_inclusion_filter + ) + ): + sha1s.add(sha1) + + self.__other_ref_sha1s = sha1s + + return self.__other_ref_sha1s + + def _get_commits_spec_incl(self, new_or_old, reference_change=None): + """Get new or old SHA-1 from one or each of the changed refs. + + Return a list of SHA-1 commit identifier strings suitable as + arguments to 'git rev-list' (or 'git log' or ...). The + returned identifiers are either the old or new values from one + or all of the changed references, depending on the values of + new_or_old and reference_change. + + new_or_old is either the string 'new' or the string 'old'. If + 'new', the returned SHA-1 identifiers are the new values from + each changed reference. If 'old', the SHA-1 identifiers are + the old values from each changed reference. + + If reference_change is specified and not None, only the new or + old reference from the specified reference is included in the + return value. + + This function returns None if there are no matching revisions + (e.g., because a branch was deleted and new_or_old is 'new'). + """ + + if not reference_change: + incl_spec = sorted( + getattr(change, new_or_old).sha1 + for change in self.changes + if getattr(change, new_or_old) + ) + if not incl_spec: + incl_spec = None + elif not getattr(reference_change, new_or_old).commit_sha1: + incl_spec = None + else: + incl_spec = [getattr(reference_change, new_or_old).commit_sha1] + return incl_spec + + def _get_commits_spec_excl(self, new_or_old): + """Get exclusion revisions for determining new or discarded commits. + + Return a list of strings suitable as arguments to 'git + rev-list' (or 'git log' or ...) that will exclude all + commits that, depending on the value of new_or_old, were + either previously in the repository (useful for determining + which commits are new to the repository) or currently in the + repository (useful for determining which commits were + discarded from the repository). + + new_or_old is either the string 'new' or the string 'old'. If + 'new', the commits to be excluded are those that were in the + repository before the push. If 'old', the commits to be + excluded are those that are currently in the repository. """ + + old_or_new = {"old": "new", "new": "old"}[new_or_old] + excl_revs = self._other_ref_sha1s.union( + getattr(change, old_or_new).sha1 + for change in self.changes + if getattr(change, old_or_new).type in ["commit", "tag"] + ) + return ["^" + sha1 for sha1 in sorted(excl_revs)] + + def get_commits_spec(self, new_or_old, reference_change=None): + """Get rev-list arguments for added or discarded commits. + + Return a list of strings suitable as arguments to 'git + rev-list' (or 'git log' or ...) that select those commits + that, depending on the value of new_or_old, are either new to + the repository or were discarded from the repository. + + new_or_old is either the string 'new' or the string 'old'. If + 'new', the returned list is used to select commits that are + new to the repository. If 'old', the returned value is used + to select the commits that have been discarded from the + repository. + + If reference_change is specified and not None, the new or + discarded commits are limited to those that are reachable from + the new or old value of the specified reference. + + This function returns None if there are no added (or discarded) + revisions. + """ + key = (new_or_old, reference_change) + if key not in self.__cached_commits_spec: + ret = self._get_commits_spec_incl(new_or_old, reference_change) + if ret is not None: + ret.extend(self._get_commits_spec_excl(new_or_old)) + self.__cached_commits_spec[key] = ret + return self.__cached_commits_spec[key] + + def get_new_commits(self, reference_change=None): + """Return a list of commits added by this push. + + Return a list of the object names of commits that were added + by the part of this push represented by reference_change. If + reference_change is None, then return a list of *all* commits + added by this push.""" + + spec = self.get_commits_spec("new", reference_change) + return git_rev_list(spec) + + def get_discarded_commits(self, reference_change): + """Return a list of commits discarded by this push. + + Return a list of the object names of commits that were + entirely discarded from the repository by the part of this + push represented by reference_change.""" + + spec = self.get_commits_spec("old", reference_change) + return git_rev_list(spec) + + def send_emails(self, mailer, body_filter=None): + """Use send all of the notification emails needed for this push. + + Use send all of the notification emails (including reference + change emails and commit emails) needed for this push. Send + the emails using mailer. If body_filter is not None, then use + it to filter the lines that are intended for the email + body.""" + + # The sha1s of commits that were introduced by this push. + # They will be removed from this set as they are processed, to + # guarantee that one (and only one) email is generated for + # each new commit. + unhandled_sha1s = set(self.get_new_commits()) + send_date = IncrementalDateTime() + for change in self.changes: + sha1s = [] + for sha1 in reversed(list(self.get_new_commits(change))): + if sha1 in unhandled_sha1s: + sha1s.append(sha1) + unhandled_sha1s.remove(sha1) + + # Check if we've got anyone to send to + if not change.recipients: + change.environment.log_warning( + "*** no recipients configured so no email will be sent\n" + "*** for %r update %s->%s" + % (change.refname, change.old.sha1, change.new.sha1) + ) + else: + if not change.environment.quiet: + change.environment.log_msg( + "Sending notification emails to: %s" + % (change.recipients,) + ) + extra_values = {"send_date": next(send_date)} + + rev = change.send_single_combined_email(sha1s) + if rev: + mailer.send( + change.generate_combined_email( + self, rev, body_filter, extra_values + ), + rev.recipients, + ) + # This change is now fully handled; no need to handle + # individual revisions any further. + continue + else: + mailer.send( + change.generate_email(self, body_filter, extra_values), + change.recipients, + ) + + max_emails = change.environment.maxcommitemails + if max_emails and len(sha1s) > max_emails: + change.environment.log_warning( + "*** Too many new commits (%d), not sending commit emails.\n" + % len(sha1s) + + "*** Try setting multimailhook.maxCommitEmails to a greater value\n" + + "*** Currently, multimailhook.maxCommitEmails=%d" + % max_emails + ) + return + + for (num, sha1) in enumerate(sha1s): + rev = Revision( + change, GitObject(sha1), num=num + 1, tot=len(sha1s) + ) + if 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 - - projectshort = get_module_name() - projectdesc = get_project_description() + 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__() - 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 +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: - mailshortdiff = False + 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: - recipients=git.config("hooks.mailinglist", _quiet=True) - except CalledProcessError: - pass - - if not recipients: - die("hooks.mailinglist is not set") - - # Get the domain name to use in the From header - try: - maildomain = git.config("hooks.maildomain", _quiet=True) - except CalledProcessError: - pass - - if not maildomain: 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__ - # Figure out a human-readable username - try: - entry = pwd.getpwuid(os.getuid()) - gecos = entry.pw_gecos - except: - gecos = None - if gecos != None: - # Typical GNOME account have John Doe 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() +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." + ) - changes = [] + if options.environment not in (None, "gerrit"): + raise SystemExit( + "Non-gerrit environments incompatible with --oldrev, " + "--newrev, --refname, and --project" + ) + options.environment = "gerrit" - 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])) + if args: + raise SystemExit( + "Error: Positional parameters not allowed with " + "--oldrev, --newrev, and --refname." + ) + + # Gerrit oddly omits 'refs/heads/' in the refname when calling + # ref-updated hook; put it back. + git_dir = get_git_dir() + if not os.path.exists( + os.path.join(git_dir, raw_refname) + ) and os.path.exists(os.path.join(git_dir, "refs", "heads", raw_refname)): + options.refname = "refs/heads/" + options.refname + + # New revisions can appear in a gerrit repository either due to someone + # pushing directly (in which case options.submitter will be set), or they + # can press "Submit this patchset" in the web UI for some CR (in which + # case options.submitter will not be set and gerrit will not have provided + # us the information about who pressed the button). + # + # Note for the nit-picky: I'm lumping in REST API calls and the ssh + # gerrit review command in with "Submit this patchset" button, since they + # have the same effect. + if options.submitter: + update_method = "pushed" + # The submitter argument is almost an RFC 2822 email address; change it + # from 'User Name (email@domain)' to 'User Name ' so it is + options.submitter = options.submitter.replace("(", "<").replace( + ")", ">" + ) else: - 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])) + 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] - for change in changes: - all_changes[change.refname] = change + # 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, + }, + ) - for change in changes: - change.prepare() - change.send_emails() - processed_changes[change.refname] = change -if __name__ == '__main__': - main() +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=" = ", + action="append", + help=( + "Pass a configuration parameter through to git. The value given " + "will override values from configuration files. See the -c option " + "of git(1) for more details. (Only works with git >= 1.7.3)" + ), + ) + parser.add_option( + "--version", + "-v", + action="store_true", + default=False, + help=("Display git-multimail's version"), + ) + + parser.add_option( + "--python-version", + action="store_true", + default=False, + help=("Display the version of Python used by git-multimail"), + ) + + parser.add_option( + "--check-ref-filter", + action="store_true", + default=False, + help=( + "List refs and show information on how git-multimail " + "will process them." + ), + ) + + # The following options permit this script to be run as a gerrit + # ref-updated hook. See e.g. + # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt + # We suppress help for these items, since these are specific to gerrit, + # and we don't want users directly using them any way other than how the + # gerrit ref-updated hook is called. + parser.add_option("--oldrev", action="store", help=optparse.SUPPRESS_HELP) + parser.add_option("--newrev", action="store", help=optparse.SUPPRESS_HELP) + parser.add_option("--refname", action="store", help=optparse.SUPPRESS_HELP) + parser.add_option("--project", action="store", help=optparse.SUPPRESS_HELP) + parser.add_option( + "--submitter", action="store", help=optparse.SUPPRESS_HELP + ) + + # The following allow this to be run as a stash asynchronous post-receive + # hook (almost identical to a git post-receive hook but triggered also for + # merges of pull requests from the UI). We suppress help for these items, + # since these are specific to stash. + parser.add_option( + "--stash-user", action="store", help=optparse.SUPPRESS_HELP + ) + parser.add_option( + "--stash-repo", action="store", help=optparse.SUPPRESS_HELP + ) + + (options, args) = parser.parse_args(args) + (options, args, hook_info) = check_hook_specific_args(options, args) + + if options.version: + sys.stdout.write("git-multimail version " + get_version() + "\n") + return + + if options.python_version: + sys.stdout.write("Python version " + sys.version + "\n") + return + + if options.c: + Config.add_config_parameters(options.c) + + config = Config("multimailhook") + + environment = None + try: + environment = choose_environment( + config, + osenv=os.environ, + env=options.environment, + recipients=options.recipients, + hook_info=hook_info, + ) + + if options.show_env: + show_env(environment, sys.stderr) + + if options.stdout or environment.stdout: + mailer = OutputMailer(sys.stdout) + else: + mailer = choose_mailer(config, environment) + + must_check_setup = os.environ.get("GIT_MULTIMAIL_CHECK_SETUP") + if must_check_setup == "": + must_check_setup = False + if options.check_ref_filter: + check_ref_filter(environment) + elif must_check_setup: + check_setup(environment) + # Dual mode: if arguments were specified on the command line, run + # like an update hook; otherwise, run as a post-receive hook. + elif args: + if len(args) != 3: + parser.error("Need zero or three non-option arguments") + (refname, oldrev, newrev) = args + environment.get_logger().debug( + "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" + % (refname, oldrev, newrev, options.force_send) + ) + run_as_update_hook( + environment, + mailer, + refname, + oldrev, + newrev, + options.force_send, + ) + else: + run_as_post_receive_hook(environment, mailer) + except ConfigurationException: + sys.exit(sys.exc_info()[1]) + except SystemExit: + raise + except Exception: + t, e, tb = sys.exc_info() + import traceback + + sys.stderr.write("\n") # Avoid mixing message with previous output + msg = ( + "Exception '" + + t.__name__ + + "' raised. Please report this as a bug to\n" + "https://github.com/git-multimail/git-multimail/issues\n" + "with the information below:\n\n" + "git-multimail version " + get_version() + "\n" + "Python version " + sys.version + "\n" + traceback.format_exc() + ) + try: + environment.get_logger().error(msg) + except: + sys.stderr.write(msg) + sys.exit(1) + + +if __name__ == "__main__": + main(sys.argv[1:])