git-multimail: update to release 1.3.0
[git.git] / contrib / hooks / multimail / git_multimail.py
blobf2c92aeed8e45545887197e7e4dfbda1b366153c
1 #! /usr/bin/env python
3 __version__ = '1.3.0'
5 # Copyright (c) 2015 Matthieu Moy and others
6 # Copyright (c) 2012-2014 Michael Haggerty and others
7 # Derived from contrib/hooks/post-receive-email, which is
8 # Copyright (c) 2007 Andy Parkins
9 # and also includes contributions by other authors.
11 # This file is part of git-multimail.
13 # git-multimail is free software: you can redistribute it and/or
14 # modify it under the terms of the GNU General Public License version
15 # 2 as published by the Free Software Foundation.
17 # This program is distributed in the hope that it will be useful, but
18 # WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
20 # General Public License for more details.
22 # You should have received a copy of the GNU General Public License
23 # along with this program. If not, see
24 # <http://www.gnu.org/licenses/>.
26 """Generate notification emails for pushes to a git repository.
28 This hook sends emails describing changes introduced by pushes to a
29 git repository. For each reference that was changed, it emits one
30 ReferenceChange email summarizing how the reference was changed,
31 followed by one Revision email for each new commit that was introduced
32 by the reference change.
34 Each commit is announced in exactly one Revision email. If the same
35 commit is merged into another branch in the same or a later push, then
36 the ReferenceChange email will list the commit's SHA1 and its one-line
37 summary, but no new Revision email will be generated.
39 This script is designed to be used as a "post-receive" hook in a git
40 repository (see githooks(5)). It can also be used as an "update"
41 script, but this usage is not completely reliable and is deprecated.
43 To help with debugging, this script accepts a --stdout option, which
44 causes the emails to be written to standard output rather than sent
45 using sendmail.
47 See the accompanying README file for the complete documentation.
49 """
51 import sys
52 import os
53 import re
54 import bisect
55 import socket
56 import subprocess
57 import shlex
58 import optparse
59 import smtplib
60 try:
61 import ssl
62 except ImportError:
63 # Python < 2.6 do not have ssl, but that's OK if we don't use it.
64 pass
65 import time
66 import cgi
68 PYTHON3 = sys.version_info >= (3, 0)
70 if sys.version_info <= (2, 5):
71 def all(iterable):
72 for element in iterable:
73 if not element:
74 return False
75 return True
78 def is_ascii(s):
79 return all(ord(c) < 128 and ord(c) > 0 for c in s)
82 if PYTHON3:
83 def is_string(s):
84 return isinstance(s, str)
86 def str_to_bytes(s):
87 return s.encode(ENCODING)
89 def bytes_to_str(s):
90 return s.decode(ENCODING)
92 unicode = str
94 def write_str(f, msg):
95 # Try outputing with the default encoding. If it fails,
96 # try UTF-8.
97 try:
98 f.buffer.write(msg.encode(sys.getdefaultencoding()))
99 except UnicodeEncodeError:
100 f.buffer.write(msg.encode(ENCODING))
101 else:
102 def is_string(s):
103 try:
104 return isinstance(s, basestring)
105 except NameError: # Silence Pyflakes warning
106 raise
108 def str_to_bytes(s):
109 return s
111 def bytes_to_str(s):
112 return s
114 def write_str(f, msg):
115 f.write(msg)
117 def next(it):
118 return it.next()
121 try:
122 from email.charset import Charset
123 from email.utils import make_msgid
124 from email.utils import getaddresses
125 from email.utils import formataddr
126 from email.utils import formatdate
127 from email.header import Header
128 except ImportError:
129 # Prior to Python 2.5, the email module used different names:
130 from email.Charset import Charset
131 from email.Utils import make_msgid
132 from email.Utils import getaddresses
133 from email.Utils import formataddr
134 from email.Utils import formatdate
135 from email.Header import Header
138 DEBUG = False
140 ZEROS = '0' * 40
141 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
142 LOGEND = '-----------------------------------------------------------------------\n'
144 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
146 # It is assumed in many places that the encoding is uniformly UTF-8,
147 # so changing these constants is unsupported. But define them here
148 # anyway, to make it easier to find (at least most of) the places
149 # where the encoding is important.
150 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
153 REF_CREATED_SUBJECT_TEMPLATE = (
154 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
155 ' (now %(newrev_short)s)'
157 REF_UPDATED_SUBJECT_TEMPLATE = (
158 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
159 ' (%(oldrev_short)s -> %(newrev_short)s)'
161 REF_DELETED_SUBJECT_TEMPLATE = (
162 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
163 ' (was %(oldrev_short)s)'
166 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
167 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
170 REFCHANGE_HEADER_TEMPLATE = """\
171 Date: %(send_date)s
172 To: %(recipients)s
173 Subject: %(subject)s
174 MIME-Version: 1.0
175 Content-Type: text/%(contenttype)s; charset=%(charset)s
176 Content-Transfer-Encoding: 8bit
177 Message-ID: %(msgid)s
178 From: %(fromaddr)s
179 Reply-To: %(reply_to)s
180 X-Git-Host: %(fqdn)s
181 X-Git-Repo: %(repo_shortname)s
182 X-Git-Refname: %(refname)s
183 X-Git-Reftype: %(refname_type)s
184 X-Git-Oldrev: %(oldrev)s
185 X-Git-Newrev: %(newrev)s
186 X-Git-NotificationType: ref_changed
187 X-Git-Multimail-Version: %(multimail_version)s
188 Auto-Submitted: auto-generated
191 REFCHANGE_INTRO_TEMPLATE = """\
192 This is an automated email from the git hooks/post-receive script.
194 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
195 in repository %(repo_shortname)s.
200 FOOTER_TEMPLATE = """\
202 -- \n\
203 To stop receiving notification emails like this one, please contact
204 %(administrator)s.
208 REWIND_ONLY_TEMPLATE = """\
209 This update removed existing revisions from the reference, leaving the
210 reference pointing at a previous point in the repository history.
212 * -- * -- N %(refname)s (%(newrev_short)s)
214 O -- O -- O (%(oldrev_short)s)
216 Any revisions marked "omits" are not gone; other references still
217 refer to them. Any revisions marked "discards" are gone forever.
221 NON_FF_TEMPLATE = """\
222 This update added new revisions after undoing existing revisions.
223 That is to say, some revisions that were in the old version of the
224 %(refname_type)s are not in the new version. This situation occurs
225 when a user --force pushes a change and generates a repository
226 containing something like this:
228 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
230 N -- N -- N %(refname)s (%(newrev_short)s)
232 You should already have received notification emails for all of the O
233 revisions, and so the following emails describe only the N revisions
234 from the common base, B.
236 Any revisions marked "omits" are not gone; other references still
237 refer to them. Any revisions marked "discards" are gone forever.
241 NO_NEW_REVISIONS_TEMPLATE = """\
242 No new revisions were added by this update.
246 DISCARDED_REVISIONS_TEMPLATE = """\
247 This change permanently discards the following revisions:
251 NO_DISCARDED_REVISIONS_TEMPLATE = """\
252 The revisions that were on this %(refname_type)s are still contained in
253 other references; therefore, this change does not discard any commits
254 from the repository.
258 NEW_REVISIONS_TEMPLATE = """\
259 The %(tot)s revisions listed above as "new" are entirely new to this
260 repository and will be described in separate emails. The revisions
261 listed as "adds" were already present in the repository and have only
262 been added to this reference.
267 TAG_CREATED_TEMPLATE = """\
268 at %(newrev_short)-9s (%(newrev_type)s)
272 TAG_UPDATED_TEMPLATE = """\
273 *** WARNING: tag %(short_refname)s was modified! ***
275 from %(oldrev_short)-9s (%(oldrev_type)s)
276 to %(newrev_short)-9s (%(newrev_type)s)
280 TAG_DELETED_TEMPLATE = """\
281 *** WARNING: tag %(short_refname)s was deleted! ***
286 # The template used in summary tables. It looks best if this uses the
287 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
288 BRIEF_SUMMARY_TEMPLATE = """\
289 %(action)10s %(rev_short)-9s %(text)s
293 NON_COMMIT_UPDATE_TEMPLATE = """\
294 This is an unusual reference change because the reference did not
295 refer to a commit either before or after the change. We do not know
296 how to provide full information about this reference change.
300 REVISION_HEADER_TEMPLATE = """\
301 Date: %(send_date)s
302 To: %(recipients)s
303 Cc: %(cc_recipients)s
304 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
305 MIME-Version: 1.0
306 Content-Type: text/%(contenttype)s; charset=%(charset)s
307 Content-Transfer-Encoding: 8bit
308 From: %(fromaddr)s
309 Reply-To: %(reply_to)s
310 In-Reply-To: %(reply_to_msgid)s
311 References: %(reply_to_msgid)s
312 X-Git-Host: %(fqdn)s
313 X-Git-Repo: %(repo_shortname)s
314 X-Git-Refname: %(refname)s
315 X-Git-Reftype: %(refname_type)s
316 X-Git-Rev: %(rev)s
317 X-Git-NotificationType: diff
318 X-Git-Multimail-Version: %(multimail_version)s
319 Auto-Submitted: auto-generated
322 REVISION_INTRO_TEMPLATE = """\
323 This is an automated email from the git hooks/post-receive script.
325 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
326 in repository %(repo_shortname)s.
330 LINK_TEXT_TEMPLATE = """\
331 View the commit online:
332 %(browse_url)s
336 LINK_HTML_TEMPLATE = """\
337 <p><a href="%(browse_url)s">View the commit online</a>.</p>
341 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
344 # Combined, meaning refchange+revision email (for single-commit additions)
345 COMBINED_HEADER_TEMPLATE = """\
346 Date: %(send_date)s
347 To: %(recipients)s
348 Subject: %(subject)s
349 MIME-Version: 1.0
350 Content-Type: text/%(contenttype)s; charset=%(charset)s
351 Content-Transfer-Encoding: 8bit
352 Message-ID: %(msgid)s
353 From: %(fromaddr)s
354 Reply-To: %(reply_to)s
355 X-Git-Host: %(fqdn)s
356 X-Git-Repo: %(repo_shortname)s
357 X-Git-Refname: %(refname)s
358 X-Git-Reftype: %(refname_type)s
359 X-Git-Oldrev: %(oldrev)s
360 X-Git-Newrev: %(newrev)s
361 X-Git-Rev: %(rev)s
362 X-Git-NotificationType: ref_changed_plus_diff
363 X-Git-Multimail-Version: %(multimail_version)s
364 Auto-Submitted: auto-generated
367 COMBINED_INTRO_TEMPLATE = """\
368 This is an automated email from the git hooks/post-receive script.
370 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
371 in repository %(repo_shortname)s.
375 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
378 class CommandError(Exception):
379 def __init__(self, cmd, retcode):
380 self.cmd = cmd
381 self.retcode = retcode
382 Exception.__init__(
383 self,
384 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
388 class ConfigurationException(Exception):
389 pass
392 # The "git" program (this could be changed to include a full path):
393 GIT_EXECUTABLE = 'git'
396 # How "git" should be invoked (including global arguments), as a list
397 # of words. This variable is usually initialized automatically by
398 # read_git_output() via choose_git_command(), but if a value is set
399 # here then it will be used unconditionally.
400 GIT_CMD = None
403 def choose_git_command():
404 """Decide how to invoke git, and record the choice in GIT_CMD."""
406 global GIT_CMD
408 if GIT_CMD is None:
409 try:
410 # Check to see whether the "-c" option is accepted (it was
411 # only added in Git 1.7.2). We don't actually use the
412 # output of "git --version", though if we needed more
413 # specific version information this would be the place to
414 # do it.
415 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
416 read_output(cmd)
417 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
418 except CommandError:
419 GIT_CMD = [GIT_EXECUTABLE]
422 def read_git_output(args, input=None, keepends=False, **kw):
423 """Read the output of a Git command."""
425 if GIT_CMD is None:
426 choose_git_command()
428 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
431 def read_output(cmd, input=None, keepends=False, **kw):
432 if input:
433 stdin = subprocess.PIPE
434 input = str_to_bytes(input)
435 else:
436 stdin = None
437 p = subprocess.Popen(
438 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
440 (out, err) = p.communicate(input)
441 out = bytes_to_str(out)
442 retcode = p.wait()
443 if retcode:
444 raise CommandError(cmd, retcode)
445 if not keepends:
446 out = out.rstrip('\n\r')
447 return out
450 def read_git_lines(args, keepends=False, **kw):
451 """Return the lines output by Git command.
453 Return as single lines, with newlines stripped off."""
455 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
458 def git_rev_list_ish(cmd, spec, args=None, **kw):
459 """Common functionality for invoking a 'git rev-list'-like command.
461 Parameters:
462 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
463 * spec is a list of revision arguments to pass to the named
464 command. If None, this function returns an empty list.
465 * args is a list of extra arguments passed to the named command.
466 * All other keyword arguments (if any) are passed to the
467 underlying read_git_lines() function.
469 Return the output of the Git command in the form of a list, one
470 entry per output line.
472 if spec is None:
473 return []
474 if args is None:
475 args = []
476 args = [cmd, '--stdin'] + args
477 spec_stdin = ''.join(s + '\n' for s in spec)
478 return read_git_lines(args, input=spec_stdin, **kw)
481 def git_rev_list(spec, **kw):
482 """Run 'git rev-list' with the given list of revision arguments.
484 See git_rev_list_ish() for parameter and return value
485 documentation.
487 return git_rev_list_ish('rev-list', spec, **kw)
490 def git_log(spec, **kw):
491 """Run 'git log' with the given list of revision arguments.
493 See git_rev_list_ish() for parameter and return value
494 documentation.
496 return git_rev_list_ish('log', spec, **kw)
499 def header_encode(text, header_name=None):
500 """Encode and line-wrap the value of an email header field."""
502 # Convert to unicode, if required.
503 if not isinstance(text, unicode):
504 text = unicode(text, 'utf-8')
506 if is_ascii(text):
507 charset = 'ascii'
508 else:
509 charset = 'utf-8'
511 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
514 def addr_header_encode(text, header_name=None):
515 """Encode and line-wrap the value of an email header field containing
516 email addresses."""
518 # Convert to unicode, if required.
519 if not isinstance(text, unicode):
520 text = unicode(text, 'utf-8')
522 text = ', '.join(
523 formataddr((header_encode(name), emailaddr))
524 for name, emailaddr in getaddresses([text])
527 if is_ascii(text):
528 charset = 'ascii'
529 else:
530 charset = 'utf-8'
532 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
535 class Config(object):
536 def __init__(self, section, git_config=None):
537 """Represent a section of the git configuration.
539 If git_config is specified, it is passed to "git config" in
540 the GIT_CONFIG environment variable, meaning that "git config"
541 will read the specified path rather than the Git default
542 config paths."""
544 self.section = section
545 if git_config:
546 self.env = os.environ.copy()
547 self.env['GIT_CONFIG'] = git_config
548 else:
549 self.env = None
551 @staticmethod
552 def _split(s):
553 """Split NUL-terminated values."""
555 words = s.split('\0')
556 assert words[-1] == ''
557 return words[:-1]
559 @staticmethod
560 def add_config_parameters(c):
561 """Add configuration parameters to Git.
563 c is either an str or a list of str, each element being of the
564 form 'var=val' or 'var', with the same syntax and meaning as
565 the argument of 'git -c var=val'.
567 if isinstance(c, str):
568 c = (c,)
569 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
570 if parameters:
571 parameters += ' '
572 # git expects GIT_CONFIG_PARAMETERS to be of the form
573 # "'name1=value1' 'name2=value2' 'name3=value3'"
574 # including everything inside the double quotes (but not the double
575 # quotes themselves). Spacing is critical. Also, if a value contains
576 # a literal single quote that quote must be represented using the
577 # four character sequence: '\''
578 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
579 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
581 def get(self, name, default=None):
582 try:
583 values = self._split(read_git_output(
584 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
585 env=self.env, keepends=True,
587 assert len(values) == 1
588 return values[0]
589 except CommandError:
590 return default
592 def get_bool(self, name, default=None):
593 try:
594 value = read_git_output(
595 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
596 env=self.env,
598 except CommandError:
599 return default
600 return value == 'true'
602 def get_all(self, name, default=None):
603 """Read a (possibly multivalued) setting from the configuration.
605 Return the result as a list of values, or default if the name
606 is unset."""
608 try:
609 return self._split(read_git_output(
610 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
611 env=self.env, keepends=True,
613 except CommandError:
614 t, e, traceback = sys.exc_info()
615 if e.retcode == 1:
616 # "the section or key is invalid"; i.e., there is no
617 # value for the specified key.
618 return default
619 else:
620 raise
622 def set(self, name, value):
623 read_git_output(
624 ['config', '%s.%s' % (self.section, name), value],
625 env=self.env,
628 def add(self, name, value):
629 read_git_output(
630 ['config', '--add', '%s.%s' % (self.section, name), value],
631 env=self.env,
634 def __contains__(self, name):
635 return self.get_all(name, default=None) is not None
637 # We don't use this method anymore internally, but keep it here in
638 # case somebody is calling it from their own code:
639 def has_key(self, name):
640 return name in self
642 def unset_all(self, name):
643 try:
644 read_git_output(
645 ['config', '--unset-all', '%s.%s' % (self.section, name)],
646 env=self.env,
648 except CommandError:
649 t, e, traceback = sys.exc_info()
650 if e.retcode == 5:
651 # The name doesn't exist, which is what we wanted anyway...
652 pass
653 else:
654 raise
656 def set_recipients(self, name, value):
657 self.unset_all(name)
658 for pair in getaddresses([value]):
659 self.add(name, formataddr(pair))
662 def generate_summaries(*log_args):
663 """Generate a brief summary for each revision requested.
665 log_args are strings that will be passed directly to "git log" as
666 revision selectors. Iterate over (sha1_short, subject) for each
667 commit specified by log_args (subject is the first line of the
668 commit message as a string without EOLs)."""
670 cmd = [
671 'log', '--abbrev', '--format=%h %s',
672 ] + list(log_args) + ['--']
673 for line in read_git_lines(cmd):
674 yield tuple(line.split(' ', 1))
677 def limit_lines(lines, max_lines):
678 for (index, line) in enumerate(lines):
679 if index < max_lines:
680 yield line
682 if index >= max_lines:
683 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
686 def limit_linelength(lines, max_linelength):
687 for line in lines:
688 # Don't forget that lines always include a trailing newline.
689 if len(line) > max_linelength + 1:
690 line = line[:max_linelength - 7] + ' [...]\n'
691 yield line
694 class CommitSet(object):
695 """A (constant) set of object names.
697 The set should be initialized with full SHA1 object names. The
698 __contains__() method returns True iff its argument is an
699 abbreviation of any the names in the set."""
701 def __init__(self, names):
702 self._names = sorted(names)
704 def __len__(self):
705 return len(self._names)
707 def __contains__(self, sha1_abbrev):
708 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
710 i = bisect.bisect_left(self._names, sha1_abbrev)
711 return i < len(self) and self._names[i].startswith(sha1_abbrev)
714 class GitObject(object):
715 def __init__(self, sha1, type=None):
716 if sha1 == ZEROS:
717 self.sha1 = self.type = self.commit_sha1 = None
718 else:
719 self.sha1 = sha1
720 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
722 if self.type == 'commit':
723 self.commit_sha1 = self.sha1
724 elif self.type == 'tag':
725 try:
726 self.commit_sha1 = read_git_output(
727 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
729 except CommandError:
730 # Cannot deref tag to determine commit_sha1
731 self.commit_sha1 = None
732 else:
733 self.commit_sha1 = None
735 self.short = read_git_output(['rev-parse', '--short', sha1])
737 def get_summary(self):
738 """Return (sha1_short, subject) for this commit."""
740 if not self.sha1:
741 raise ValueError('Empty commit has no summary')
743 return next(iter(generate_summaries('--no-walk', self.sha1)))
745 def __eq__(self, other):
746 return isinstance(other, GitObject) and self.sha1 == other.sha1
748 def __hash__(self):
749 return hash(self.sha1)
751 def __nonzero__(self):
752 return bool(self.sha1)
754 def __bool__(self):
755 """Python 2 backward compatibility"""
756 return self.__nonzero__()
758 def __str__(self):
759 return self.sha1 or ZEROS
762 class Change(object):
763 """A Change that has been made to the Git repository.
765 Abstract class from which both Revisions and ReferenceChanges are
766 derived. A Change knows how to generate a notification email
767 describing itself."""
769 def __init__(self, environment):
770 self.environment = environment
771 self._values = None
772 self._contains_html_diff = False
774 def _contains_diff(self):
775 # We do contain a diff, should it be rendered in HTML?
776 if self.environment.commit_email_format == "html":
777 self._contains_html_diff = True
779 def _compute_values(self):
780 """Return a dictionary {keyword: expansion} for this Change.
782 Derived classes overload this method to add more entries to
783 the return value. This method is used internally by
784 get_values(). The return value should always be a new
785 dictionary."""
787 values = self.environment.get_values()
788 fromaddr = self.environment.get_fromaddr(change=self)
789 if fromaddr is not None:
790 values['fromaddr'] = fromaddr
791 values['multimail_version'] = get_version()
792 return values
794 # Aliases usable in template strings. Tuple of pairs (destination,
795 # source).
796 VALUES_ALIAS = (
797 ("id", "newrev"),
800 def get_values(self, **extra_values):
801 """Return a dictionary {keyword: expansion} for this Change.
803 Return a dictionary mapping keywords to the values that they
804 should be expanded to for this Change (used when interpolating
805 template strings). If any keyword arguments are supplied, add
806 those to the return value as well. The return value is always
807 a new dictionary."""
809 if self._values is None:
810 self._values = self._compute_values()
812 values = self._values.copy()
813 if extra_values:
814 values.update(extra_values)
816 for alias, val in self.VALUES_ALIAS:
817 values[alias] = values[val]
818 return values
820 def expand(self, template, **extra_values):
821 """Expand template.
823 Expand the template (which should be a string) using string
824 interpolation of the values for this Change. If any keyword
825 arguments are provided, also include those in the keywords
826 available for interpolation."""
828 return template % self.get_values(**extra_values)
830 def expand_lines(self, template, html_escape_val=False, **extra_values):
831 """Break template into lines and expand each line."""
833 values = self.get_values(**extra_values)
834 if html_escape_val:
835 for k in values:
836 if is_string(values[k]):
837 values[k] = cgi.escape(values[k], True)
838 for line in template.splitlines(True):
839 yield line % values
841 def expand_header_lines(self, template, **extra_values):
842 """Break template into lines and expand each line as an RFC 2822 header.
844 Encode values and split up lines that are too long. Silently
845 skip lines that contain references to unknown variables."""
847 values = self.get_values(**extra_values)
848 if self._contains_html_diff:
849 self._content_type = 'html'
850 else:
851 self._content_type = 'plain'
852 values['contenttype'] = self._content_type
854 for line in template.splitlines():
855 (name, value) = line.split(': ', 1)
857 try:
858 value = value % values
859 except KeyError:
860 t, e, traceback = sys.exc_info()
861 if DEBUG:
862 self.environment.log_warning(
863 'Warning: unknown variable %r in the following line; line skipped:\n'
864 ' %s\n'
865 % (e.args[0], line,)
867 else:
868 if name.lower() in ADDR_HEADERS:
869 value = addr_header_encode(value, name)
870 else:
871 value = header_encode(value, name)
872 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
873 yield splitline
875 def generate_email_header(self):
876 """Generate the RFC 2822 email headers for this Change, a line at a time.
878 The output should not include the trailing blank line."""
880 raise NotImplementedError()
882 def generate_browse_link(self, base_url):
883 """Generate a link to an online repository browser."""
884 return iter(())
886 def generate_email_intro(self, html_escape_val=False):
887 """Generate the email intro for this Change, a line at a time.
889 The output will be used as the standard boilerplate at the top
890 of the email body."""
892 raise NotImplementedError()
894 def generate_email_body(self):
895 """Generate the main part of the email body, a line at a time.
897 The text in the body might be truncated after a specified
898 number of lines (see multimailhook.emailmaxlines)."""
900 raise NotImplementedError()
902 def generate_email_footer(self, html_escape_val):
903 """Generate the footer of the email, a line at a time.
905 The footer is always included, irrespective of
906 multimailhook.emailmaxlines."""
908 raise NotImplementedError()
910 def _wrap_for_html(self, lines):
911 """Wrap the lines in HTML <pre> tag when using HTML format.
913 Escape special HTML characters and add <pre> and </pre> tags around
914 the given lines if we should be generating HTML as indicated by
915 self._contains_html_diff being set to true.
917 if self._contains_html_diff:
918 yield "<pre style='margin:0'>\n"
920 for line in lines:
921 yield cgi.escape(line)
923 yield '</pre>\n'
924 else:
925 for line in lines:
926 yield line
928 def generate_email(self, push, body_filter=None, extra_header_values={}):
929 """Generate an email describing this change.
931 Iterate over the lines (including the header lines) of an
932 email describing this change. If body_filter is not None,
933 then use it to filter the lines that are intended for the
934 email body.
936 The extra_header_values field is received as a dict and not as
937 **kwargs, to allow passing other keyword arguments in the
938 future (e.g. passing extra values to generate_email_intro()"""
940 for line in self.generate_email_header(**extra_header_values):
941 yield line
942 yield '\n'
943 html_escape_val = (self.environment.html_in_intro and
944 self._contains_html_diff)
945 intro = self.generate_email_intro(html_escape_val)
946 if not self.environment.html_in_intro:
947 intro = self._wrap_for_html(intro)
948 for line in intro:
949 yield line
951 if self.environment.commitBrowseURL:
952 for line in self.generate_browse_link(self.environment.commitBrowseURL):
953 yield line
955 body = self.generate_email_body(push)
956 if body_filter is not None:
957 body = body_filter(body)
959 diff_started = False
960 if self._contains_html_diff:
961 # "white-space: pre" is the default, but we need to
962 # specify it again in case the message is viewed in a
963 # webmail which wraps it in an element setting white-space
964 # to something else (Zimbra does this and sets
965 # white-space: pre-line).
966 yield '<pre style="white-space: pre; background: #F8F8F8">'
967 for line in body:
968 if self._contains_html_diff:
969 # This is very, very naive. It would be much better to really
970 # parse the diff, i.e. look at how many lines do we have in
971 # the hunk headers instead of blindly highlighting everything
972 # that looks like it might be part of a diff.
973 bgcolor = ''
974 fgcolor = ''
975 if line.startswith('--- a/'):
976 diff_started = True
977 bgcolor = 'e0e0ff'
978 elif line.startswith('diff ') or line.startswith('index '):
979 diff_started = True
980 fgcolor = '808080'
981 elif diff_started:
982 if line.startswith('+++ '):
983 bgcolor = 'e0e0ff'
984 elif line.startswith('@@'):
985 bgcolor = 'e0e0e0'
986 elif line.startswith('+'):
987 bgcolor = 'e0ffe0'
988 elif line.startswith('-'):
989 bgcolor = 'ffe0e0'
990 elif line.startswith('commit '):
991 fgcolor = '808000'
992 elif line.startswith(' '):
993 fgcolor = '404040'
995 # Chop the trailing LF, we don't want it inside <pre>.
996 line = cgi.escape(line[:-1])
998 if bgcolor or fgcolor:
999 style = 'display:block; white-space:pre;'
1000 if bgcolor:
1001 style += 'background:#' + bgcolor + ';'
1002 if fgcolor:
1003 style += 'color:#' + fgcolor + ';'
1004 # Use a <span style='display:block> to color the
1005 # whole line. The newline must be inside the span
1006 # to display properly both in Firefox and in
1007 # text-based browser.
1008 line = "<span style='%s'>%s\n</span>" % (style, line)
1009 else:
1010 line = line + '\n'
1012 yield line
1013 if self._contains_html_diff:
1014 yield '</pre>'
1015 html_escape_val = (self.environment.html_in_footer and
1016 self._contains_html_diff)
1017 footer = self.generate_email_footer(html_escape_val)
1018 if not self.environment.html_in_footer:
1019 footer = self._wrap_for_html(footer)
1020 for line in footer:
1021 yield line
1023 def get_alt_fromaddr(self):
1024 return None
1027 class Revision(Change):
1028 """A Change consisting of a single git commit."""
1030 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1032 def __init__(self, reference_change, rev, num, tot):
1033 Change.__init__(self, reference_change.environment)
1034 self.reference_change = reference_change
1035 self.rev = rev
1036 self.change_type = self.reference_change.change_type
1037 self.refname = self.reference_change.refname
1038 self.num = num
1039 self.tot = tot
1040 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1041 self.recipients = self.environment.get_revision_recipients(self)
1043 self.cc_recipients = ''
1044 if self.environment.get_scancommitforcc():
1045 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1046 if self.cc_recipients:
1047 self.environment.log_msg(
1048 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
1050 def _cc_recipients(self):
1051 cc_recipients = []
1052 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1053 lines = message.strip().split('\n')
1054 for line in lines:
1055 m = re.match(self.CC_RE, line)
1056 if m:
1057 cc_recipients.append(m.group('to'))
1059 return cc_recipients
1061 def _compute_values(self):
1062 values = Change._compute_values(self)
1064 oneline = read_git_output(
1065 ['log', '--format=%s', '--no-walk', self.rev.sha1]
1068 values['rev'] = self.rev.sha1
1069 values['rev_short'] = self.rev.short
1070 values['change_type'] = self.change_type
1071 values['refname'] = self.refname
1072 values['newrev'] = self.rev.sha1
1073 values['short_refname'] = self.reference_change.short_refname
1074 values['refname_type'] = self.reference_change.refname_type
1075 values['reply_to_msgid'] = self.reference_change.msgid
1076 values['num'] = self.num
1077 values['tot'] = self.tot
1078 values['recipients'] = self.recipients
1079 if self.cc_recipients:
1080 values['cc_recipients'] = self.cc_recipients
1081 values['oneline'] = oneline
1082 values['author'] = self.author
1084 reply_to = self.environment.get_reply_to_commit(self)
1085 if reply_to:
1086 values['reply_to'] = reply_to
1088 return values
1090 def generate_email_header(self, **extra_values):
1091 for line in self.expand_header_lines(
1092 REVISION_HEADER_TEMPLATE, **extra_values
1094 yield line
1096 def generate_browse_link(self, base_url):
1097 if '%(' not in base_url:
1098 base_url += '%(id)s'
1099 url = "".join(self.expand_lines(base_url))
1100 if self._content_type == 'html':
1101 for line in self.expand_lines(LINK_HTML_TEMPLATE,
1102 html_escape_val=True,
1103 browse_url=url):
1104 yield line
1105 elif self._content_type == 'plain':
1106 for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1107 html_escape_val=False,
1108 browse_url=url):
1109 yield line
1110 else:
1111 raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1113 def generate_email_intro(self, html_escape_val=False):
1114 for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1115 html_escape_val=html_escape_val):
1116 yield line
1118 def generate_email_body(self, push):
1119 """Show this revision."""
1121 for line in read_git_lines(
1122 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1123 keepends=True,
1125 if line.startswith('Date: ') and self.environment.date_substitute:
1126 yield self.environment.date_substitute + line[len('Date: '):]
1127 else:
1128 yield line
1130 def generate_email_footer(self, html_escape_val):
1131 return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1132 html_escape_val=html_escape_val)
1134 def generate_email(self, push, body_filter=None, extra_header_values={}):
1135 self._contains_diff()
1136 return Change.generate_email(self, push, body_filter, extra_header_values)
1138 def get_alt_fromaddr(self):
1139 return self.environment.from_commit
1142 class ReferenceChange(Change):
1143 """A Change to a Git reference.
1145 An abstract class representing a create, update, or delete of a
1146 Git reference. Derived classes handle specific types of reference
1147 (e.g., tags vs. branches). These classes generate the main
1148 reference change email summarizing the reference change and
1149 whether it caused any any commits to be added or removed.
1151 ReferenceChange objects are usually created using the static
1152 create() method, which has the logic to decide which derived class
1153 to instantiate."""
1155 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1157 @staticmethod
1158 def create(environment, oldrev, newrev, refname):
1159 """Return a ReferenceChange object representing the change.
1161 Return an object that represents the type of change that is being
1162 made. oldrev and newrev should be SHA1s or ZEROS."""
1164 old = GitObject(oldrev)
1165 new = GitObject(newrev)
1166 rev = new or old
1168 # The revision type tells us what type the commit is, combined with
1169 # the location of the ref we can decide between
1170 # - working branch
1171 # - tracking branch
1172 # - unannotated tag
1173 # - annotated tag
1174 m = ReferenceChange.REF_RE.match(refname)
1175 if m:
1176 area = m.group('area')
1177 short_refname = m.group('shortname')
1178 else:
1179 area = ''
1180 short_refname = refname
1182 if rev.type == 'tag':
1183 # Annotated tag:
1184 klass = AnnotatedTagChange
1185 elif rev.type == 'commit':
1186 if area == 'tags':
1187 # Non-annotated tag:
1188 klass = NonAnnotatedTagChange
1189 elif area == 'heads':
1190 # Branch:
1191 klass = BranchChange
1192 elif area == 'remotes':
1193 # Tracking branch:
1194 environment.log_warning(
1195 '*** Push-update of tracking branch %r\n'
1196 '*** - incomplete email generated.\n'
1197 % (refname,)
1199 klass = OtherReferenceChange
1200 else:
1201 # Some other reference namespace:
1202 environment.log_warning(
1203 '*** Push-update of strange reference %r\n'
1204 '*** - incomplete email generated.\n'
1205 % (refname,)
1207 klass = OtherReferenceChange
1208 else:
1209 # Anything else (is there anything else?)
1210 environment.log_warning(
1211 '*** Unknown type of update to %r (%s)\n'
1212 '*** - incomplete email generated.\n'
1213 % (refname, rev.type,)
1215 klass = OtherReferenceChange
1217 return klass(
1218 environment,
1219 refname=refname, short_refname=short_refname,
1220 old=old, new=new, rev=rev,
1223 def __init__(self, environment, refname, short_refname, old, new, rev):
1224 Change.__init__(self, environment)
1225 self.change_type = {
1226 (False, True): 'create',
1227 (True, True): 'update',
1228 (True, False): 'delete',
1229 }[bool(old), bool(new)]
1230 self.refname = refname
1231 self.short_refname = short_refname
1232 self.old = old
1233 self.new = new
1234 self.rev = rev
1235 self.msgid = make_msgid()
1236 self.diffopts = environment.diffopts
1237 self.graphopts = environment.graphopts
1238 self.logopts = environment.logopts
1239 self.commitlogopts = environment.commitlogopts
1240 self.showgraph = environment.refchange_showgraph
1241 self.showlog = environment.refchange_showlog
1243 self.header_template = REFCHANGE_HEADER_TEMPLATE
1244 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1245 self.footer_template = FOOTER_TEMPLATE
1247 def _compute_values(self):
1248 values = Change._compute_values(self)
1250 values['change_type'] = self.change_type
1251 values['refname_type'] = self.refname_type
1252 values['refname'] = self.refname
1253 values['short_refname'] = self.short_refname
1254 values['msgid'] = self.msgid
1255 values['recipients'] = self.recipients
1256 values['oldrev'] = str(self.old)
1257 values['oldrev_short'] = self.old.short
1258 values['newrev'] = str(self.new)
1259 values['newrev_short'] = self.new.short
1261 if self.old:
1262 values['oldrev_type'] = self.old.type
1263 if self.new:
1264 values['newrev_type'] = self.new.type
1266 reply_to = self.environment.get_reply_to_refchange(self)
1267 if reply_to:
1268 values['reply_to'] = reply_to
1270 return values
1272 def send_single_combined_email(self, known_added_sha1s):
1273 """Determine if a combined refchange/revision email should be sent
1275 If there is only a single new (non-merge) commit added by a
1276 change, it is useful to combine the ReferenceChange and
1277 Revision emails into one. In such a case, return the single
1278 revision; otherwise, return None.
1280 This method is overridden in BranchChange."""
1282 return None
1284 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1285 """Generate an email describing this change AND specified revision.
1287 Iterate over the lines (including the header lines) of an
1288 email describing this change. If body_filter is not None,
1289 then use it to filter the lines that are intended for the
1290 email body.
1292 The extra_header_values field is received as a dict and not as
1293 **kwargs, to allow passing other keyword arguments in the
1294 future (e.g. passing extra values to generate_email_intro()
1296 This method is overridden in BranchChange."""
1298 raise NotImplementedError
1300 def get_subject(self):
1301 template = {
1302 'create': REF_CREATED_SUBJECT_TEMPLATE,
1303 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1304 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1305 }[self.change_type]
1306 return self.expand(template)
1308 def generate_email_header(self, **extra_values):
1309 if 'subject' not in extra_values:
1310 extra_values['subject'] = self.get_subject()
1312 for line in self.expand_header_lines(
1313 self.header_template, **extra_values
1315 yield line
1317 def generate_email_intro(self, html_escape_val=False):
1318 for line in self.expand_lines(self.intro_template,
1319 html_escape_val=html_escape_val):
1320 yield line
1322 def generate_email_body(self, push):
1323 """Call the appropriate body-generation routine.
1325 Call one of generate_create_summary() /
1326 generate_update_summary() / generate_delete_summary()."""
1328 change_summary = {
1329 'create': self.generate_create_summary,
1330 'delete': self.generate_delete_summary,
1331 'update': self.generate_update_summary,
1332 }[self.change_type](push)
1333 for line in change_summary:
1334 yield line
1336 for line in self.generate_revision_change_summary(push):
1337 yield line
1339 def generate_email_footer(self, html_escape_val):
1340 return self.expand_lines(self.footer_template,
1341 html_escape_val=html_escape_val)
1343 def generate_revision_change_graph(self, push):
1344 if self.showgraph:
1345 args = ['--graph'] + self.graphopts
1346 for newold in ('new', 'old'):
1347 has_newold = False
1348 spec = push.get_commits_spec(newold, self)
1349 for line in git_log(spec, args=args, keepends=True):
1350 if not has_newold:
1351 has_newold = True
1352 yield '\n'
1353 yield 'Graph of %s commits:\n\n' % (
1354 {'new': 'new', 'old': 'discarded'}[newold],)
1355 yield ' ' + line
1356 if has_newold:
1357 yield '\n'
1359 def generate_revision_change_log(self, new_commits_list):
1360 if self.showlog:
1361 yield '\n'
1362 yield 'Detailed log of new commits:\n\n'
1363 for line in read_git_lines(
1364 ['log', '--no-walk'] +
1365 self.logopts +
1366 new_commits_list +
1367 ['--'],
1368 keepends=True,
1370 yield line
1372 def generate_new_revision_summary(self, tot, new_commits_list, push):
1373 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1374 yield line
1375 for line in self.generate_revision_change_graph(push):
1376 yield line
1377 for line in self.generate_revision_change_log(new_commits_list):
1378 yield line
1380 def generate_revision_change_summary(self, push):
1381 """Generate a summary of the revisions added/removed by this change."""
1383 if self.new.commit_sha1 and not self.old.commit_sha1:
1384 # A new reference was created. List the new revisions
1385 # brought by the new reference (i.e., those revisions that
1386 # were not in the repository before this reference
1387 # change).
1388 sha1s = list(push.get_new_commits(self))
1389 sha1s.reverse()
1390 tot = len(sha1s)
1391 new_revisions = [
1392 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1393 for (i, sha1) in enumerate(sha1s)
1396 if new_revisions:
1397 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1398 yield '\n'
1399 for r in new_revisions:
1400 (sha1, subject) = r.rev.get_summary()
1401 yield r.expand(
1402 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1404 yield '\n'
1405 for line in self.generate_new_revision_summary(
1406 tot, [r.rev.sha1 for r in new_revisions], push):
1407 yield line
1408 else:
1409 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1410 yield line
1412 elif self.new.commit_sha1 and self.old.commit_sha1:
1413 # A reference was changed to point at a different commit.
1414 # List the revisions that were removed and/or added *from
1415 # that reference* by this reference change, along with a
1416 # diff between the trees for its old and new values.
1418 # List of the revisions that were added to the branch by
1419 # this update. Note this list can include revisions that
1420 # have already had notification emails; we want such
1421 # revisions in the summary even though we will not send
1422 # new notification emails for them.
1423 adds = list(generate_summaries(
1424 '--topo-order', '--reverse', '%s..%s'
1425 % (self.old.commit_sha1, self.new.commit_sha1,)
1428 # List of the revisions that were removed from the branch
1429 # by this update. This will be empty except for
1430 # non-fast-forward updates.
1431 discards = list(generate_summaries(
1432 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1435 if adds:
1436 new_commits_list = push.get_new_commits(self)
1437 else:
1438 new_commits_list = []
1439 new_commits = CommitSet(new_commits_list)
1441 if discards:
1442 discarded_commits = CommitSet(push.get_discarded_commits(self))
1443 else:
1444 discarded_commits = CommitSet([])
1446 if discards and adds:
1447 for (sha1, subject) in discards:
1448 if sha1 in discarded_commits:
1449 action = 'discards'
1450 else:
1451 action = 'omits'
1452 yield self.expand(
1453 BRIEF_SUMMARY_TEMPLATE, action=action,
1454 rev_short=sha1, text=subject,
1456 for (sha1, subject) in adds:
1457 if sha1 in new_commits:
1458 action = 'new'
1459 else:
1460 action = 'adds'
1461 yield self.expand(
1462 BRIEF_SUMMARY_TEMPLATE, action=action,
1463 rev_short=sha1, text=subject,
1465 yield '\n'
1466 for line in self.expand_lines(NON_FF_TEMPLATE):
1467 yield line
1469 elif discards:
1470 for (sha1, subject) in discards:
1471 if sha1 in discarded_commits:
1472 action = 'discards'
1473 else:
1474 action = 'omits'
1475 yield self.expand(
1476 BRIEF_SUMMARY_TEMPLATE, action=action,
1477 rev_short=sha1, text=subject,
1479 yield '\n'
1480 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1481 yield line
1483 elif adds:
1484 (sha1, subject) = self.old.get_summary()
1485 yield self.expand(
1486 BRIEF_SUMMARY_TEMPLATE, action='from',
1487 rev_short=sha1, text=subject,
1489 for (sha1, subject) in adds:
1490 if sha1 in new_commits:
1491 action = 'new'
1492 else:
1493 action = 'adds'
1494 yield self.expand(
1495 BRIEF_SUMMARY_TEMPLATE, action=action,
1496 rev_short=sha1, text=subject,
1499 yield '\n'
1501 if new_commits:
1502 for line in self.generate_new_revision_summary(
1503 len(new_commits), new_commits_list, push):
1504 yield line
1505 else:
1506 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1507 yield line
1508 for line in self.generate_revision_change_graph(push):
1509 yield line
1511 # The diffstat is shown from the old revision to the new
1512 # revision. This is to show the truth of what happened in
1513 # this change. There's no point showing the stat from the
1514 # base to the new revision because the base is effectively a
1515 # random revision at this point - the user will be interested
1516 # in what this revision changed - including the undoing of
1517 # previous revisions in the case of non-fast-forward updates.
1518 yield '\n'
1519 yield 'Summary of changes:\n'
1520 for line in read_git_lines(
1521 ['diff-tree'] +
1522 self.diffopts +
1523 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1524 keepends=True,
1526 yield line
1528 elif self.old.commit_sha1 and not self.new.commit_sha1:
1529 # A reference was deleted. List the revisions that were
1530 # removed from the repository by this reference change.
1532 sha1s = list(push.get_discarded_commits(self))
1533 tot = len(sha1s)
1534 discarded_revisions = [
1535 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1536 for (i, sha1) in enumerate(sha1s)
1539 if discarded_revisions:
1540 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1541 yield line
1542 yield '\n'
1543 for r in discarded_revisions:
1544 (sha1, subject) = r.rev.get_summary()
1545 yield r.expand(
1546 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1548 for line in self.generate_revision_change_graph(push):
1549 yield line
1550 else:
1551 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1552 yield line
1554 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1555 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1556 yield line
1558 def generate_create_summary(self, push):
1559 """Called for the creation of a reference."""
1561 # This is a new reference and so oldrev is not valid
1562 (sha1, subject) = self.new.get_summary()
1563 yield self.expand(
1564 BRIEF_SUMMARY_TEMPLATE, action='at',
1565 rev_short=sha1, text=subject,
1567 yield '\n'
1569 def generate_update_summary(self, push):
1570 """Called for the change of a pre-existing branch."""
1572 return iter([])
1574 def generate_delete_summary(self, push):
1575 """Called for the deletion of any type of reference."""
1577 (sha1, subject) = self.old.get_summary()
1578 yield self.expand(
1579 BRIEF_SUMMARY_TEMPLATE, action='was',
1580 rev_short=sha1, text=subject,
1582 yield '\n'
1584 def get_alt_fromaddr(self):
1585 return self.environment.from_refchange
1588 class BranchChange(ReferenceChange):
1589 refname_type = 'branch'
1591 def __init__(self, environment, refname, short_refname, old, new, rev):
1592 ReferenceChange.__init__(
1593 self, environment,
1594 refname=refname, short_refname=short_refname,
1595 old=old, new=new, rev=rev,
1597 self.recipients = environment.get_refchange_recipients(self)
1598 self._single_revision = None
1600 def send_single_combined_email(self, known_added_sha1s):
1601 if not self.environment.combine_when_single_commit:
1602 return None
1604 # In the sadly-all-too-frequent usecase of people pushing only
1605 # one of their commits at a time to a repository, users feel
1606 # the reference change summary emails are noise rather than
1607 # important signal. This is because, in this particular
1608 # usecase, there is a reference change summary email for each
1609 # new commit, and all these summaries do is point out that
1610 # there is one new commit (which can readily be inferred by
1611 # the existence of the individual revision email that is also
1612 # sent). In such cases, our users prefer there to be a combined
1613 # reference change summary/new revision email.
1615 # So, if the change is an update and it doesn't discard any
1616 # commits, and it adds exactly one non-merge commit (gerrit
1617 # forces a workflow where every commit is individually merged
1618 # and the git-multimail hook fired off for just this one
1619 # change), then we send a combined refchange/revision email.
1620 try:
1621 # If this change is a reference update that doesn't discard
1622 # any commits...
1623 if self.change_type != 'update':
1624 return None
1626 if read_git_lines(
1627 ['merge-base', self.old.sha1, self.new.sha1]
1628 ) != [self.old.sha1]:
1629 return None
1631 # Check if this update introduced exactly one non-merge
1632 # commit:
1634 def split_line(line):
1635 """Split line into (sha1, [parent,...])."""
1637 words = line.split()
1638 return (words[0], words[1:])
1640 # Get the new commits introduced by the push as a list of
1641 # (sha1, [parent,...])
1642 new_commits = [
1643 split_line(line)
1644 for line in read_git_lines(
1646 'log', '-3', '--format=%H %P',
1647 '%s..%s' % (self.old.sha1, self.new.sha1),
1652 if not new_commits:
1653 return None
1655 # If the newest commit is a merge, save it for a later check
1656 # but otherwise ignore it
1657 merge = None
1658 tot = len(new_commits)
1659 if len(new_commits[0][1]) > 1:
1660 merge = new_commits[0][0]
1661 del new_commits[0]
1663 # Our primary check: we can't combine if more than one commit
1664 # is introduced. We also currently only combine if the new
1665 # commit is a non-merge commit, though it may make sense to
1666 # combine if it is a merge as well.
1667 if not (
1668 len(new_commits) == 1 and
1669 len(new_commits[0][1]) == 1 and
1670 new_commits[0][0] in known_added_sha1s
1672 return None
1674 # We do not want to combine revision and refchange emails if
1675 # those go to separate locations.
1676 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1677 if rev.recipients != self.recipients:
1678 return None
1680 # We ignored the newest commit if it was just a merge of the one
1681 # commit being introduced. But we don't want to ignore that
1682 # merge commit it it involved conflict resolutions. Check that.
1683 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1684 return None
1686 # We can combine the refchange and one new revision emails
1687 # into one. Return the Revision that a combined email should
1688 # be sent about.
1689 return rev
1690 except CommandError:
1691 # Cannot determine number of commits in old..new or new..old;
1692 # don't combine reference/revision emails:
1693 return None
1695 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1696 values = revision.get_values()
1697 if extra_header_values:
1698 values.update(extra_header_values)
1699 if 'subject' not in extra_header_values:
1700 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1702 self._single_revision = revision
1703 self._contains_diff()
1704 self.header_template = COMBINED_HEADER_TEMPLATE
1705 self.intro_template = COMBINED_INTRO_TEMPLATE
1706 self.footer_template = COMBINED_FOOTER_TEMPLATE
1707 for line in self.generate_email(push, body_filter, values):
1708 yield line
1710 def generate_email_body(self, push):
1711 '''Call the appropriate body generation routine.
1713 If this is a combined refchange/revision email, the special logic
1714 for handling this combined email comes from this function. For
1715 other cases, we just use the normal handling.'''
1717 # If self._single_revision isn't set; don't override
1718 if not self._single_revision:
1719 for line in super(BranchChange, self).generate_email_body(push):
1720 yield line
1721 return
1723 # This is a combined refchange/revision email; we first provide
1724 # some info from the refchange portion, and then call the revision
1725 # generate_email_body function to handle the revision portion.
1726 adds = list(generate_summaries(
1727 '--topo-order', '--reverse', '%s..%s'
1728 % (self.old.commit_sha1, self.new.commit_sha1,)
1731 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1732 for (sha1, subject) in adds:
1733 yield self.expand(
1734 BRIEF_SUMMARY_TEMPLATE, action='new',
1735 rev_short=sha1, text=subject,
1738 yield self._single_revision.rev.short + " is described below\n"
1739 yield '\n'
1741 for line in self._single_revision.generate_email_body(push):
1742 yield line
1745 class AnnotatedTagChange(ReferenceChange):
1746 refname_type = 'annotated tag'
1748 def __init__(self, environment, refname, short_refname, old, new, rev):
1749 ReferenceChange.__init__(
1750 self, environment,
1751 refname=refname, short_refname=short_refname,
1752 old=old, new=new, rev=rev,
1754 self.recipients = environment.get_announce_recipients(self)
1755 self.show_shortlog = environment.announce_show_shortlog
1757 ANNOTATED_TAG_FORMAT = (
1758 '%(*objectname)\n'
1759 '%(*objecttype)\n'
1760 '%(taggername)\n'
1761 '%(taggerdate)'
1764 def describe_tag(self, push):
1765 """Describe the new value of an annotated tag."""
1767 # Use git for-each-ref to pull out the individual fields from
1768 # the tag
1769 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1770 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1773 yield self.expand(
1774 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1775 rev_short=tagobject, text='(%s)' % (tagtype,),
1777 if tagtype == 'commit':
1778 # If the tagged object is a commit, then we assume this is a
1779 # release, and so we calculate which tag this tag is
1780 # replacing
1781 try:
1782 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1783 except CommandError:
1784 prevtag = None
1785 if prevtag:
1786 yield ' replaces %s\n' % (prevtag,)
1787 else:
1788 prevtag = None
1789 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1791 yield ' tagged by %s\n' % (tagger,)
1792 yield ' on %s\n' % (tagged,)
1793 yield '\n'
1795 # Show the content of the tag message; this might contain a
1796 # change log or release notes so is worth displaying.
1797 yield LOGBEGIN
1798 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1799 contents = contents[contents.index('\n') + 1:]
1800 if contents and contents[-1][-1:] != '\n':
1801 contents.append('\n')
1802 for line in contents:
1803 yield line
1805 if self.show_shortlog and tagtype == 'commit':
1806 # Only commit tags make sense to have rev-list operations
1807 # performed on them
1808 yield '\n'
1809 if prevtag:
1810 # Show changes since the previous release
1811 revlist = read_git_output(
1812 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1813 keepends=True,
1815 else:
1816 # No previous tag, show all the changes since time
1817 # began
1818 revlist = read_git_output(
1819 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1820 keepends=True,
1822 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1823 yield line
1825 yield LOGEND
1826 yield '\n'
1828 def generate_create_summary(self, push):
1829 """Called for the creation of an annotated tag."""
1831 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1832 yield line
1834 for line in self.describe_tag(push):
1835 yield line
1837 def generate_update_summary(self, push):
1838 """Called for the update of an annotated tag.
1840 This is probably a rare event and may not even be allowed."""
1842 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1843 yield line
1845 for line in self.describe_tag(push):
1846 yield line
1848 def generate_delete_summary(self, push):
1849 """Called when a non-annotated reference is updated."""
1851 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1852 yield line
1854 yield self.expand(' tag was %(oldrev_short)s\n')
1855 yield '\n'
1858 class NonAnnotatedTagChange(ReferenceChange):
1859 refname_type = 'tag'
1861 def __init__(self, environment, refname, short_refname, old, new, rev):
1862 ReferenceChange.__init__(
1863 self, environment,
1864 refname=refname, short_refname=short_refname,
1865 old=old, new=new, rev=rev,
1867 self.recipients = environment.get_refchange_recipients(self)
1869 def generate_create_summary(self, push):
1870 """Called for the creation of an annotated tag."""
1872 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1873 yield line
1875 def generate_update_summary(self, push):
1876 """Called when a non-annotated reference is updated."""
1878 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1879 yield line
1881 def generate_delete_summary(self, push):
1882 """Called when a non-annotated reference is updated."""
1884 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1885 yield line
1887 for line in ReferenceChange.generate_delete_summary(self, push):
1888 yield line
1891 class OtherReferenceChange(ReferenceChange):
1892 refname_type = 'reference'
1894 def __init__(self, environment, refname, short_refname, old, new, rev):
1895 # We use the full refname as short_refname, because otherwise
1896 # the full name of the reference would not be obvious from the
1897 # text of the email.
1898 ReferenceChange.__init__(
1899 self, environment,
1900 refname=refname, short_refname=refname,
1901 old=old, new=new, rev=rev,
1903 self.recipients = environment.get_refchange_recipients(self)
1906 class Mailer(object):
1907 """An object that can send emails."""
1909 def send(self, lines, to_addrs):
1910 """Send an email consisting of lines.
1912 lines must be an iterable over the lines constituting the
1913 header and body of the email. to_addrs is a list of recipient
1914 addresses (can be needed even if lines already contains a
1915 "To:" field). It can be either a string (comma-separated list
1916 of email addresses) or a Python list of individual email
1917 addresses.
1921 raise NotImplementedError()
1924 class SendMailer(Mailer):
1925 """Send emails using 'sendmail -oi -t'."""
1927 SENDMAIL_CANDIDATES = [
1928 '/usr/sbin/sendmail',
1929 '/usr/lib/sendmail',
1932 @staticmethod
1933 def find_sendmail():
1934 for path in SendMailer.SENDMAIL_CANDIDATES:
1935 if os.access(path, os.X_OK):
1936 return path
1937 else:
1938 raise ConfigurationException(
1939 'No sendmail executable found. '
1940 'Try setting multimailhook.sendmailCommand.'
1943 def __init__(self, command=None, envelopesender=None):
1944 """Construct a SendMailer instance.
1946 command should be the command and arguments used to invoke
1947 sendmail, as a list of strings. If an envelopesender is
1948 provided, it will also be passed to the command, via '-f
1949 envelopesender'."""
1951 if command:
1952 self.command = command[:]
1953 else:
1954 self.command = [self.find_sendmail(), '-oi', '-t']
1956 if envelopesender:
1957 self.command.extend(['-f', envelopesender])
1959 def send(self, lines, to_addrs):
1960 try:
1961 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1962 except OSError:
1963 sys.stderr.write(
1964 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
1965 '*** %s\n' % sys.exc_info()[1] +
1966 '*** Try setting multimailhook.mailer to "smtp"\n' +
1967 '*** to send emails without using the sendmail command.\n'
1969 sys.exit(1)
1970 try:
1971 lines = (str_to_bytes(line) for line in lines)
1972 p.stdin.writelines(lines)
1973 except Exception:
1974 sys.stderr.write(
1975 '*** Error while generating commit email\n'
1976 '*** - mail sending aborted.\n'
1978 try:
1979 # subprocess.terminate() is not available in Python 2.4
1980 p.terminate()
1981 except AttributeError:
1982 pass
1983 raise
1984 else:
1985 p.stdin.close()
1986 retcode = p.wait()
1987 if retcode:
1988 raise CommandError(self.command, retcode)
1991 class SMTPMailer(Mailer):
1992 """Send emails using Python's smtplib."""
1994 def __init__(self, envelopesender, smtpserver,
1995 smtpservertimeout=10.0, smtpserverdebuglevel=0,
1996 smtpencryption='none',
1997 smtpuser='', smtppass='',
1998 smtpcacerts=''
2000 if not envelopesender:
2001 sys.stderr.write(
2002 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2003 'please set either multimailhook.envelopeSender or user.email\n'
2005 sys.exit(1)
2006 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2007 raise ConfigurationException(
2008 'Cannot use SMTPMailer with security option ssl '
2009 'without options username and password.'
2011 self.envelopesender = envelopesender
2012 self.smtpserver = smtpserver
2013 self.smtpservertimeout = smtpservertimeout
2014 self.smtpserverdebuglevel = smtpserverdebuglevel
2015 self.security = smtpencryption
2016 self.username = smtpuser
2017 self.password = smtppass
2018 self.smtpcacerts = smtpcacerts
2019 try:
2020 def call(klass, server, timeout):
2021 try:
2022 return klass(server, timeout=timeout)
2023 except TypeError:
2024 # Old Python versions do not have timeout= argument.
2025 return klass(server)
2026 if self.security == 'none':
2027 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2028 elif self.security == 'ssl':
2029 if self.smtpcacerts:
2030 raise smtplib.SMTPException(
2031 "Checking certificate is not supported for ssl, prefer starttls"
2033 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2034 elif self.security == 'tls':
2035 if 'ssl' not in sys.modules:
2036 sys.stderr.write(
2037 '*** Your Python version does not have the ssl library installed\n'
2038 '*** smtpEncryption=tls is not available.\n'
2039 '*** Either upgrade Python to 2.6 or later\n'
2040 ' or use git_multimail.py version 1.2.\n')
2041 if ':' not in self.smtpserver:
2042 self.smtpserver += ':587' # default port for TLS
2043 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2044 # start: ehlo + starttls
2045 # equivalent to
2046 # self.smtp.ehlo()
2047 # self.smtp.starttls()
2048 # with acces to the ssl layer
2049 self.smtp.ehlo()
2050 if not self.smtp.has_extn("starttls"):
2051 raise smtplib.SMTPException("STARTTLS extension not supported by server")
2052 resp, reply = self.smtp.docmd("STARTTLS")
2053 if resp != 220:
2054 raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2055 if self.smtpcacerts:
2056 self.smtp.sock = ssl.wrap_socket(
2057 self.smtp.sock,
2058 ca_certs=self.smtpcacerts,
2059 cert_reqs=ssl.CERT_REQUIRED
2061 else:
2062 self.smtp.sock = ssl.wrap_socket(
2063 self.smtp.sock,
2064 cert_reqs=ssl.CERT_NONE
2066 sys.stderr.write(
2067 '*** Warning, the server certificat is not verified (smtp) ***\n'
2068 '*** set the option smtpCACerts ***\n'
2070 if not hasattr(self.smtp.sock, "read"):
2071 # using httplib.FakeSocket with Python 2.5.x or earlier
2072 self.smtp.sock.read = self.smtp.sock.recv
2073 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2074 self.smtp.helo_resp = None
2075 self.smtp.ehlo_resp = None
2076 self.smtp.esmtp_features = {}
2077 self.smtp.does_esmtp = 0
2078 # end: ehlo + starttls
2079 self.smtp.ehlo()
2080 else:
2081 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2082 sys.exit(1)
2083 if self.smtpserverdebuglevel > 0:
2084 sys.stdout.write(
2085 "*** Setting debug on for SMTP server connection (%s) ***\n"
2086 % self.smtpserverdebuglevel)
2087 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2088 except Exception:
2089 sys.stderr.write(
2090 '*** Error establishing SMTP connection to %s ***\n'
2091 % self.smtpserver)
2092 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2093 sys.exit(1)
2095 def __del__(self):
2096 if hasattr(self, 'smtp'):
2097 self.smtp.quit()
2098 del self.smtp
2100 def send(self, lines, to_addrs):
2101 try:
2102 if self.username or self.password:
2103 self.smtp.login(self.username, self.password)
2104 msg = ''.join(lines)
2105 # turn comma-separated list into Python list if needed.
2106 if is_string(to_addrs):
2107 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2108 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2109 except smtplib.SMTPResponseException:
2110 sys.stderr.write('*** Error sending email ***\n')
2111 err = sys.exc_info()[1]
2112 sys.stderr.write('*** Error %d: %s\n' % (err.smtp_code,
2113 bytes_to_str(err.smtp_error)))
2114 try:
2115 smtp = self.smtp
2116 # delete the field before quit() so that in case of
2117 # error, self.smtp is deleted anyway.
2118 del self.smtp
2119 smtp.quit()
2120 except:
2121 sys.stderr.write('*** Error closing the SMTP connection ***\n')
2122 sys.stderr.write('*** Exiting anyway ... ***\n')
2123 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2124 sys.exit(1)
2127 class OutputMailer(Mailer):
2128 """Write emails to an output stream, bracketed by lines of '=' characters.
2130 This is intended for debugging purposes."""
2132 SEPARATOR = '=' * 75 + '\n'
2134 def __init__(self, f):
2135 self.f = f
2137 def send(self, lines, to_addrs):
2138 write_str(self.f, self.SEPARATOR)
2139 for line in lines:
2140 write_str(self.f, line)
2141 write_str(self.f, self.SEPARATOR)
2144 def get_git_dir():
2145 """Determine GIT_DIR.
2147 Determine GIT_DIR either from the GIT_DIR environment variable or
2148 from the working directory, using Git's usual rules."""
2150 try:
2151 return read_git_output(['rev-parse', '--git-dir'])
2152 except CommandError:
2153 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2154 sys.exit(1)
2157 class Environment(object):
2158 """Describes the environment in which the push is occurring.
2160 An Environment object encapsulates information about the local
2161 environment. For example, it knows how to determine:
2163 * the name of the repository to which the push occurred
2165 * what user did the push
2167 * what users want to be informed about various types of changes.
2169 An Environment object is expected to have the following methods:
2171 get_repo_shortname()
2173 Return a short name for the repository, for display
2174 purposes.
2176 get_repo_path()
2178 Return the absolute path to the Git repository.
2180 get_emailprefix()
2182 Return a string that will be prefixed to every email's
2183 subject.
2185 get_pusher()
2187 Return the username of the person who pushed the changes.
2188 This value is used in the email body to indicate who
2189 pushed the change.
2191 get_pusher_email() (may return None)
2193 Return the email address of the person who pushed the
2194 changes. The value should be a single RFC 2822 email
2195 address as a string; e.g., "Joe User <user@example.com>"
2196 if available, otherwise "user@example.com". If set, the
2197 value is used as the Reply-To address for refchange
2198 emails. If it is impossible to determine the pusher's
2199 email, this attribute should be set to None (in which case
2200 no Reply-To header will be output).
2202 get_sender()
2204 Return the address to be used as the 'From' email address
2205 in the email envelope.
2207 get_fromaddr(change=None)
2209 Return the 'From' email address used in the email 'From:'
2210 headers. If the change is known when this function is
2211 called, it is passed in as the 'change' parameter. (May
2212 be a full RFC 2822 email address like 'Joe User
2213 <user@example.com>'.)
2215 get_administrator()
2217 Return the name and/or email of the repository
2218 administrator. This value is used in the footer as the
2219 person to whom requests to be removed from the
2220 notification list should be sent. Ideally, it should
2221 include a valid email address.
2223 get_reply_to_refchange()
2224 get_reply_to_commit()
2226 Return the address to use in the email "Reply-To" header,
2227 as a string. These can be an RFC 2822 email address, or
2228 None to omit the "Reply-To" header.
2229 get_reply_to_refchange() is used for refchange emails;
2230 get_reply_to_commit() is used for individual commit
2231 emails.
2233 get_ref_filter_regex()
2235 Return a tuple -- a compiled regex, and a boolean indicating
2236 whether the regex picks refs to include (if False, the regex
2237 matches on refs to exclude).
2239 get_default_ref_ignore_regex()
2241 Return a regex that should be ignored for both what emails
2242 to send and when computing what commits are considered new
2243 to the repository. Default is "^refs/notes/".
2245 They should also define the following attributes:
2247 announce_show_shortlog (bool)
2249 True iff announce emails should include a shortlog.
2251 commit_email_format (string)
2253 If "html", generate commit emails in HTML instead of plain text
2254 used by default.
2256 html_in_intro (bool)
2257 html_in_footer (bool)
2259 When generating HTML emails, the introduction (respectively,
2260 the footer) will be HTML-escaped iff html_in_intro (respectively,
2261 the footer) is true. When false, only the values used to expand
2262 the template are escaped.
2264 refchange_showgraph (bool)
2266 True iff refchanges emails should include a detailed graph.
2268 refchange_showlog (bool)
2270 True iff refchanges emails should include a detailed log.
2272 diffopts (list of strings)
2274 The options that should be passed to 'git diff' for the
2275 summary email. The value should be a list of strings
2276 representing words to be passed to the command.
2278 graphopts (list of strings)
2280 Analogous to diffopts, but contains options passed to
2281 'git log --graph' when generating the detailed graph for
2282 a set of commits (see refchange_showgraph)
2284 logopts (list of strings)
2286 Analogous to diffopts, but contains options passed to
2287 'git log' when generating the detailed log for a set of
2288 commits (see refchange_showlog)
2290 commitlogopts (list of strings)
2292 The options that should be passed to 'git log' for each
2293 commit mail. The value should be a list of strings
2294 representing words to be passed to the command.
2296 date_substitute (string)
2298 String to be used in substitution for 'Date:' at start of
2299 line in the output of 'git log'.
2301 quiet (bool)
2302 On success do not write to stderr
2304 stdout (bool)
2305 Write email to stdout rather than emailing. Useful for debugging
2307 combine_when_single_commit (bool)
2309 True if a combined email should be produced when a single
2310 new commit is pushed to a branch, False otherwise.
2312 from_refchange, from_commit (strings)
2314 Addresses to use for the From: field for refchange emails
2315 and commit emails respectively. Set from
2316 multimailhook.fromRefchange and multimailhook.fromCommit
2317 by ConfigEnvironmentMixin.
2321 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2323 def __init__(self, osenv=None):
2324 self.osenv = osenv or os.environ
2325 self.announce_show_shortlog = False
2326 self.commit_email_format = "text"
2327 self.html_in_intro = False
2328 self.html_in_footer = False
2329 self.commitBrowseURL = None
2330 self.maxcommitemails = 500
2331 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2332 self.graphopts = ['--oneline', '--decorate']
2333 self.logopts = []
2334 self.refchange_showgraph = False
2335 self.refchange_showlog = False
2336 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2337 self.date_substitute = 'AuthorDate: '
2338 self.quiet = False
2339 self.stdout = False
2340 self.combine_when_single_commit = True
2342 self.COMPUTED_KEYS = [
2343 'administrator',
2344 'charset',
2345 'emailprefix',
2346 'pusher',
2347 'pusher_email',
2348 'repo_path',
2349 'repo_shortname',
2350 'sender',
2353 self._values = None
2355 def get_repo_shortname(self):
2356 """Use the last part of the repo path, with ".git" stripped off if present."""
2358 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2359 m = self.REPO_NAME_RE.match(basename)
2360 if m:
2361 return m.group('name')
2362 else:
2363 return basename
2365 def get_pusher(self):
2366 raise NotImplementedError()
2368 def get_pusher_email(self):
2369 return None
2371 def get_fromaddr(self, change=None):
2372 config = Config('user')
2373 fromname = config.get('name', default='')
2374 fromemail = config.get('email', default='')
2375 if fromemail:
2376 return formataddr([fromname, fromemail])
2377 return self.get_sender()
2379 def get_administrator(self):
2380 return 'the administrator of this repository'
2382 def get_emailprefix(self):
2383 return ''
2385 def get_repo_path(self):
2386 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2387 path = get_git_dir()
2388 else:
2389 path = read_git_output(['rev-parse', '--show-toplevel'])
2390 return os.path.abspath(path)
2392 def get_charset(self):
2393 return CHARSET
2395 def get_values(self):
2396 """Return a dictionary {keyword: expansion} for this Environment.
2398 This method is called by Change._compute_values(). The keys
2399 in the returned dictionary are available to be used in any of
2400 the templates. The dictionary is created by calling
2401 self.get_NAME() for each of the attributes named in
2402 COMPUTED_KEYS and recording those that do not return None.
2403 The return value is always a new dictionary."""
2405 if self._values is None:
2406 values = {'': ''} # %()s expands to the empty string.
2408 for key in self.COMPUTED_KEYS:
2409 value = getattr(self, 'get_%s' % (key,))()
2410 if value is not None:
2411 values[key] = value
2413 self._values = values
2415 return self._values.copy()
2417 def get_refchange_recipients(self, refchange):
2418 """Return the recipients for notifications about refchange.
2420 Return the list of email addresses to which notifications
2421 about the specified ReferenceChange should be sent."""
2423 raise NotImplementedError()
2425 def get_announce_recipients(self, annotated_tag_change):
2426 """Return the recipients for notifications about annotated_tag_change.
2428 Return the list of email addresses to which notifications
2429 about the specified AnnotatedTagChange should be sent."""
2431 raise NotImplementedError()
2433 def get_reply_to_refchange(self, refchange):
2434 return self.get_pusher_email()
2436 def get_revision_recipients(self, revision):
2437 """Return the recipients for messages about revision.
2439 Return the list of email addresses to which notifications
2440 about the specified Revision should be sent. This method
2441 could be overridden, for example, to take into account the
2442 contents of the revision when deciding whom to notify about
2443 it. For example, there could be a scheme for users to express
2444 interest in particular files or subdirectories, and only
2445 receive notification emails for revisions that affecting those
2446 files."""
2448 raise NotImplementedError()
2450 def get_reply_to_commit(self, revision):
2451 return revision.author
2453 def get_default_ref_ignore_regex(self):
2454 # The commit messages of git notes are essentially meaningless
2455 # and "filenames" in git notes commits are an implementational
2456 # detail that might surprise users at first. As such, we
2457 # would need a completely different method for handling emails
2458 # of git notes in order for them to be of benefit for users,
2459 # which we simply do not have right now.
2460 return "^refs/notes/"
2462 def filter_body(self, lines):
2463 """Filter the lines intended for an email body.
2465 lines is an iterable over the lines that would go into the
2466 email body. Filter it (e.g., limit the number of lines, the
2467 line length, character set, etc.), returning another iterable.
2468 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2469 for classes implementing this functionality."""
2471 return lines
2473 def log_msg(self, msg):
2474 """Write the string msg on a log file or on stderr.
2476 Sends the text to stderr by default, override to change the behavior."""
2477 write_str(sys.stderr, msg)
2479 def log_warning(self, msg):
2480 """Write the string msg on a log file or on stderr.
2482 Sends the text to stderr by default, override to change the behavior."""
2483 write_str(sys.stderr, msg)
2485 def log_error(self, msg):
2486 """Write the string msg on a log file or on stderr.
2488 Sends the text to stderr by default, override to change the behavior."""
2489 write_str(sys.stderr, msg)
2492 class ConfigEnvironmentMixin(Environment):
2493 """A mixin that sets self.config to its constructor's config argument.
2495 This class's constructor consumes the "config" argument.
2497 Mixins that need to inspect the config should inherit from this
2498 class (1) to make sure that "config" is still in the constructor
2499 arguments with its own constructor runs and/or (2) to be sure that
2500 self.config is set after construction."""
2502 def __init__(self, config, **kw):
2503 super(ConfigEnvironmentMixin, self).__init__(**kw)
2504 self.config = config
2507 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2508 """An Environment that reads most of its information from "git config"."""
2510 @staticmethod
2511 def forbid_field_values(name, value, forbidden):
2512 for forbidden_val in forbidden:
2513 if value is not None and value.lower() == forbidden:
2514 raise ConfigurationException(
2515 '"%s" is not an allowed setting for %s' % (value, name)
2518 def __init__(self, config, **kw):
2519 super(ConfigOptionsEnvironmentMixin, self).__init__(
2520 config=config, **kw
2523 for var, cfg in (
2524 ('announce_show_shortlog', 'announceshortlog'),
2525 ('refchange_showgraph', 'refchangeShowGraph'),
2526 ('refchange_showlog', 'refchangeshowlog'),
2527 ('quiet', 'quiet'),
2528 ('stdout', 'stdout'),
2530 val = config.get_bool(cfg)
2531 if val is not None:
2532 setattr(self, var, val)
2534 commit_email_format = config.get('commitEmailFormat')
2535 if commit_email_format is not None:
2536 if commit_email_format != "html" and commit_email_format != "text":
2537 self.log_warning(
2538 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2539 commit_email_format +
2540 '*** Expected either "text" or "html". Ignoring.\n'
2542 else:
2543 self.commit_email_format = commit_email_format
2545 html_in_intro = config.get_bool('htmlInIntro')
2546 if html_in_intro is not None:
2547 self.html_in_intro = html_in_intro
2549 html_in_footer = config.get_bool('htmlInFooter')
2550 if html_in_footer is not None:
2551 self.html_in_footer = html_in_footer
2553 self.commitBrowseURL = config.get('commitBrowseURL')
2555 maxcommitemails = config.get('maxcommitemails')
2556 if maxcommitemails is not None:
2557 try:
2558 self.maxcommitemails = int(maxcommitemails)
2559 except ValueError:
2560 self.log_warning(
2561 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2562 % maxcommitemails +
2563 '*** Expected a number. Ignoring.\n'
2566 diffopts = config.get('diffopts')
2567 if diffopts is not None:
2568 self.diffopts = shlex.split(diffopts)
2570 graphopts = config.get('graphOpts')
2571 if graphopts is not None:
2572 self.graphopts = shlex.split(graphopts)
2574 logopts = config.get('logopts')
2575 if logopts is not None:
2576 self.logopts = shlex.split(logopts)
2578 commitlogopts = config.get('commitlogopts')
2579 if commitlogopts is not None:
2580 self.commitlogopts = shlex.split(commitlogopts)
2582 date_substitute = config.get('dateSubstitute')
2583 if date_substitute == 'none':
2584 self.date_substitute = None
2585 elif date_substitute is not None:
2586 self.date_substitute = date_substitute
2588 reply_to = config.get('replyTo')
2589 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2590 self.forbid_field_values('replyToRefchange',
2591 self.__reply_to_refchange,
2592 ['author'])
2593 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2595 self.from_refchange = config.get('fromRefchange')
2596 self.forbid_field_values('fromRefchange',
2597 self.from_refchange,
2598 ['author', 'none'])
2599 self.from_commit = config.get('fromCommit')
2600 self.forbid_field_values('fromCommit',
2601 self.from_commit,
2602 ['none'])
2604 combine = config.get_bool('combineWhenSingleCommit')
2605 if combine is not None:
2606 self.combine_when_single_commit = combine
2608 def get_administrator(self):
2609 return (
2610 self.config.get('administrator') or
2611 self.get_sender() or
2612 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2615 def get_repo_shortname(self):
2616 return (
2617 self.config.get('reponame') or
2618 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2621 def get_emailprefix(self):
2622 emailprefix = self.config.get('emailprefix')
2623 if emailprefix is not None:
2624 emailprefix = emailprefix.strip()
2625 if emailprefix:
2626 return emailprefix + ' '
2627 else:
2628 return ''
2629 else:
2630 return '[%s] ' % (self.get_repo_shortname(),)
2632 def get_sender(self):
2633 return self.config.get('envelopesender')
2635 def process_addr(self, addr, change):
2636 if addr.lower() == 'author':
2637 if hasattr(change, 'author'):
2638 return change.author
2639 else:
2640 return None
2641 elif addr.lower() == 'pusher':
2642 return self.get_pusher_email()
2643 elif addr.lower() == 'none':
2644 return None
2645 else:
2646 return addr
2648 def get_fromaddr(self, change=None):
2649 fromaddr = self.config.get('from')
2650 if change:
2651 alt_fromaddr = change.get_alt_fromaddr()
2652 if alt_fromaddr:
2653 fromaddr = alt_fromaddr
2654 if fromaddr:
2655 fromaddr = self.process_addr(fromaddr, change)
2656 if fromaddr:
2657 return fromaddr
2658 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2660 def get_reply_to_refchange(self, refchange):
2661 if self.__reply_to_refchange is None:
2662 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2663 else:
2664 return self.process_addr(self.__reply_to_refchange, refchange)
2666 def get_reply_to_commit(self, revision):
2667 if self.__reply_to_commit is None:
2668 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2669 else:
2670 return self.process_addr(self.__reply_to_commit, revision)
2672 def get_scancommitforcc(self):
2673 return self.config.get('scancommitforcc')
2676 class FilterLinesEnvironmentMixin(Environment):
2677 """Handle encoding and maximum line length of body lines.
2679 emailmaxlinelength (int or None)
2681 The maximum length of any single line in the email body.
2682 Longer lines are truncated at that length with ' [...]'
2683 appended.
2685 strict_utf8 (bool)
2687 If this field is set to True, then the email body text is
2688 expected to be UTF-8. Any invalid characters are
2689 converted to U+FFFD, the Unicode replacement character
2690 (encoded as UTF-8, of course).
2694 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
2695 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2696 self.__strict_utf8 = strict_utf8
2697 self.__emailmaxlinelength = emailmaxlinelength
2699 def filter_body(self, lines):
2700 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2701 if self.__strict_utf8:
2702 if not PYTHON3:
2703 lines = (line.decode(ENCODING, 'replace') for line in lines)
2704 # Limit the line length in Unicode-space to avoid
2705 # splitting characters:
2706 if self.__emailmaxlinelength:
2707 lines = limit_linelength(lines, self.__emailmaxlinelength)
2708 if not PYTHON3:
2709 lines = (line.encode(ENCODING, 'replace') for line in lines)
2710 elif self.__emailmaxlinelength:
2711 lines = limit_linelength(lines, self.__emailmaxlinelength)
2713 return lines
2716 class ConfigFilterLinesEnvironmentMixin(
2717 ConfigEnvironmentMixin,
2718 FilterLinesEnvironmentMixin,
2720 """Handle encoding and maximum line length based on config."""
2722 def __init__(self, config, **kw):
2723 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2724 if strict_utf8 is not None:
2725 kw['strict_utf8'] = strict_utf8
2727 emailmaxlinelength = config.get('emailmaxlinelength')
2728 if emailmaxlinelength is not None:
2729 kw['emailmaxlinelength'] = int(emailmaxlinelength)
2731 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2732 config=config, **kw
2736 class MaxlinesEnvironmentMixin(Environment):
2737 """Limit the email body to a specified number of lines."""
2739 def __init__(self, emailmaxlines, **kw):
2740 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2741 self.__emailmaxlines = emailmaxlines
2743 def filter_body(self, lines):
2744 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2745 if self.__emailmaxlines:
2746 lines = limit_lines(lines, self.__emailmaxlines)
2747 return lines
2750 class ConfigMaxlinesEnvironmentMixin(
2751 ConfigEnvironmentMixin,
2752 MaxlinesEnvironmentMixin,
2754 """Limit the email body to the number of lines specified in config."""
2756 def __init__(self, config, **kw):
2757 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2758 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2759 config=config,
2760 emailmaxlines=emailmaxlines,
2761 **kw
2765 class FQDNEnvironmentMixin(Environment):
2766 """A mixin that sets the host's FQDN to its constructor argument."""
2768 def __init__(self, fqdn, **kw):
2769 super(FQDNEnvironmentMixin, self).__init__(**kw)
2770 self.COMPUTED_KEYS += ['fqdn']
2771 self.__fqdn = fqdn
2773 def get_fqdn(self):
2774 """Return the fully-qualified domain name for this host.
2776 Return None if it is unavailable or unwanted."""
2778 return self.__fqdn
2781 class ConfigFQDNEnvironmentMixin(
2782 ConfigEnvironmentMixin,
2783 FQDNEnvironmentMixin,
2785 """Read the FQDN from the config."""
2787 def __init__(self, config, **kw):
2788 fqdn = config.get('fqdn')
2789 super(ConfigFQDNEnvironmentMixin, self).__init__(
2790 config=config,
2791 fqdn=fqdn,
2792 **kw
2796 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2797 """Get the FQDN by calling socket.getfqdn()."""
2799 def __init__(self, **kw):
2800 super(ComputeFQDNEnvironmentMixin, self).__init__(
2801 fqdn=socket.getfqdn(),
2802 **kw
2806 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2807 """Deduce pusher_email from pusher by appending an emaildomain."""
2809 def __init__(self, **kw):
2810 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2811 self.__emaildomain = self.config.get('emaildomain')
2813 def get_pusher_email(self):
2814 if self.__emaildomain:
2815 # Derive the pusher's full email address in the default way:
2816 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2817 else:
2818 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2821 class StaticRecipientsEnvironmentMixin(Environment):
2822 """Set recipients statically based on constructor parameters."""
2824 def __init__(
2825 self,
2826 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2827 **kw
2829 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2831 # The recipients for various types of notification emails, as
2832 # RFC 2822 email addresses separated by commas (or the empty
2833 # string if no recipients are configured). Although there is
2834 # a mechanism to choose the recipient lists based on on the
2835 # actual *contents* of the change being reported, we only
2836 # choose based on the *type* of the change. Therefore we can
2837 # compute them once and for all:
2838 if not (refchange_recipients or
2839 announce_recipients or
2840 revision_recipients or
2841 scancommitforcc):
2842 raise ConfigurationException('No email recipients configured!')
2843 self.__refchange_recipients = refchange_recipients
2844 self.__announce_recipients = announce_recipients
2845 self.__revision_recipients = revision_recipients
2847 def get_refchange_recipients(self, refchange):
2848 return self.__refchange_recipients
2850 def get_announce_recipients(self, annotated_tag_change):
2851 return self.__announce_recipients
2853 def get_revision_recipients(self, revision):
2854 return self.__revision_recipients
2857 class ConfigRecipientsEnvironmentMixin(
2858 ConfigEnvironmentMixin,
2859 StaticRecipientsEnvironmentMixin
2861 """Determine recipients statically based on config."""
2863 def __init__(self, config, **kw):
2864 super(ConfigRecipientsEnvironmentMixin, self).__init__(
2865 config=config,
2866 refchange_recipients=self._get_recipients(
2867 config, 'refchangelist', 'mailinglist',
2869 announce_recipients=self._get_recipients(
2870 config, 'announcelist', 'refchangelist', 'mailinglist',
2872 revision_recipients=self._get_recipients(
2873 config, 'commitlist', 'mailinglist',
2875 scancommitforcc=config.get('scancommitforcc'),
2876 **kw
2879 def _get_recipients(self, config, *names):
2880 """Return the recipients for a particular type of message.
2882 Return the list of email addresses to which a particular type
2883 of notification email should be sent, by looking at the config
2884 value for "multimailhook.$name" for each of names. Use the
2885 value from the first name that is configured. The return
2886 value is a (possibly empty) string containing RFC 2822 email
2887 addresses separated by commas. If no configuration could be
2888 found, raise a ConfigurationException."""
2890 for name in names:
2891 lines = config.get_all(name)
2892 if lines is not None:
2893 lines = [line.strip() for line in lines]
2894 # Single "none" is a special value equivalen to empty string.
2895 if lines == ['none']:
2896 lines = ['']
2897 return ', '.join(lines)
2898 else:
2899 return ''
2902 class StaticRefFilterEnvironmentMixin(Environment):
2903 """Set branch filter statically based on constructor parameters."""
2905 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
2906 ref_filter_do_send_regex, ref_filter_dont_send_regex,
2907 **kw):
2908 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
2910 if ref_filter_incl_regex and ref_filter_excl_regex:
2911 raise ConfigurationException(
2912 "Cannot specify both a ref inclusion and exclusion regex.")
2913 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
2914 default_exclude = self.get_default_ref_ignore_regex()
2915 if ref_filter_incl_regex:
2916 ref_filter_regex = ref_filter_incl_regex
2917 elif ref_filter_excl_regex:
2918 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
2919 else:
2920 ref_filter_regex = default_exclude
2921 try:
2922 self.__compiled_regex = re.compile(ref_filter_regex)
2923 except Exception:
2924 raise ConfigurationException(
2925 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
2927 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
2928 raise ConfigurationException(
2929 "Cannot specify both a ref doSend and dontSend regex.")
2930 if ref_filter_do_send_regex or ref_filter_dont_send_regex:
2931 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
2932 if ref_filter_incl_regex:
2933 ref_filter_send_regex = ref_filter_incl_regex
2934 elif ref_filter_excl_regex:
2935 ref_filter_send_regex = ref_filter_excl_regex
2936 else:
2937 ref_filter_send_regex = '.*'
2938 self.__is_do_send_filter = True
2939 try:
2940 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
2941 except Exception:
2942 raise ConfigurationException(
2943 'Invalid Ref Filter Regex "%s": %s' %
2944 (ref_filter_send_regex, sys.exc_info()[1]))
2945 else:
2946 self.__send_compiled_regex = self.__compiled_regex
2947 self.__is_do_send_filter = self.__is_inclusion_filter
2949 def get_ref_filter_regex(self, send_filter=False):
2950 if send_filter:
2951 return self.__send_compiled_regex, self.__is_do_send_filter
2952 else:
2953 return self.__compiled_regex, self.__is_inclusion_filter
2956 class ConfigRefFilterEnvironmentMixin(
2957 ConfigEnvironmentMixin,
2958 StaticRefFilterEnvironmentMixin
2960 """Determine branch filtering statically based on config."""
2962 def _get_regex(self, config, key):
2963 """Get a list of whitespace-separated regex. The refFilter* config
2964 variables are multivalued (hence the use of get_all), and we
2965 allow each entry to be a whitespace-separated list (hence the
2966 split on each line). The whole thing is glued into a single regex."""
2967 values = config.get_all(key)
2968 if values is None:
2969 return values
2970 items = []
2971 for line in values:
2972 for i in line.split():
2973 items.append(i)
2974 if items == []:
2975 return None
2976 return '|'.join(items)
2978 def __init__(self, config, **kw):
2979 super(ConfigRefFilterEnvironmentMixin, self).__init__(
2980 config=config,
2981 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
2982 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
2983 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
2984 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
2985 **kw
2989 class ProjectdescEnvironmentMixin(Environment):
2990 """Make a "projectdesc" value available for templates.
2992 By default, it is set to the first line of $GIT_DIR/description
2993 (if that file is present and appears to be set meaningfully)."""
2995 def __init__(self, **kw):
2996 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2997 self.COMPUTED_KEYS += ['projectdesc']
2999 def get_projectdesc(self):
3000 """Return a one-line descripition of the project."""
3002 git_dir = get_git_dir()
3003 try:
3004 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3005 if projectdesc and not projectdesc.startswith('Unnamed repository'):
3006 return projectdesc
3007 except IOError:
3008 pass
3010 return 'UNNAMED PROJECT'
3013 class GenericEnvironmentMixin(Environment):
3014 def get_pusher(self):
3015 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3018 class GenericEnvironment(
3019 ProjectdescEnvironmentMixin,
3020 ConfigMaxlinesEnvironmentMixin,
3021 ComputeFQDNEnvironmentMixin,
3022 ConfigFilterLinesEnvironmentMixin,
3023 ConfigRecipientsEnvironmentMixin,
3024 ConfigRefFilterEnvironmentMixin,
3025 PusherDomainEnvironmentMixin,
3026 ConfigOptionsEnvironmentMixin,
3027 GenericEnvironmentMixin,
3028 Environment,
3030 pass
3033 class GitoliteEnvironmentMixin(Environment):
3034 def get_repo_shortname(self):
3035 # The gitolite environment variable $GL_REPO is a pretty good
3036 # repo_shortname (though it's probably not as good as a value
3037 # the user might have explicitly put in his config).
3038 return (
3039 self.osenv.get('GL_REPO', None) or
3040 super(GitoliteEnvironmentMixin, self).get_repo_shortname()
3043 def get_pusher(self):
3044 return self.osenv.get('GL_USER', 'unknown user')
3046 def get_fromaddr(self, change=None):
3047 GL_USER = self.osenv.get('GL_USER')
3048 if GL_USER is not None:
3049 # Find the path to gitolite.conf. Note that gitolite v3
3050 # did away with the GL_ADMINDIR and GL_CONF environment
3051 # variables (they are now hard-coded).
3052 GL_ADMINDIR = self.osenv.get(
3053 'GL_ADMINDIR',
3054 os.path.expanduser(os.path.join('~', '.gitolite')))
3055 GL_CONF = self.osenv.get(
3056 'GL_CONF',
3057 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3058 if os.path.isfile(GL_CONF):
3059 f = open(GL_CONF, 'rU')
3060 try:
3061 in_user_emails_section = False
3062 re_template = r'^\s*#\s*%s\s*$'
3063 re_begin, re_user, re_end = (
3064 re.compile(re_template % x)
3065 for x in (
3066 r'BEGIN\s+USER\s+EMAILS',
3067 re.escape(GL_USER) + r'\s+(.*)',
3068 r'END\s+USER\s+EMAILS',
3070 for l in f:
3071 l = l.rstrip('\n')
3072 if not in_user_emails_section:
3073 if re_begin.match(l):
3074 in_user_emails_section = True
3075 continue
3076 if re_end.match(l):
3077 break
3078 m = re_user.match(l)
3079 if m:
3080 return m.group(1)
3081 finally:
3082 f.close()
3083 return super(GitoliteEnvironmentMixin, self).get_fromaddr(change)
3086 class IncrementalDateTime(object):
3087 """Simple wrapper to give incremental date/times.
3089 Each call will result in a date/time a second later than the
3090 previous call. This can be used to falsify email headers, to
3091 increase the likelihood that email clients sort the emails
3092 correctly."""
3094 def __init__(self):
3095 self.time = time.time()
3096 self.next = self.__next__ # Python 2 backward compatibility
3098 def __next__(self):
3099 formatted = formatdate(self.time, True)
3100 self.time += 1
3101 return formatted
3104 class GitoliteEnvironment(
3105 ProjectdescEnvironmentMixin,
3106 ConfigMaxlinesEnvironmentMixin,
3107 ComputeFQDNEnvironmentMixin,
3108 ConfigFilterLinesEnvironmentMixin,
3109 ConfigRecipientsEnvironmentMixin,
3110 ConfigRefFilterEnvironmentMixin,
3111 PusherDomainEnvironmentMixin,
3112 ConfigOptionsEnvironmentMixin,
3113 GitoliteEnvironmentMixin,
3114 Environment,
3116 pass
3119 class StashEnvironmentMixin(Environment):
3120 def __init__(self, user=None, repo=None, **kw):
3121 super(StashEnvironmentMixin, self).__init__(**kw)
3122 self.__user = user
3123 self.__repo = repo
3125 def get_repo_shortname(self):
3126 return self.__repo
3128 def get_pusher(self):
3129 return re.match('(.*?)\s*<', self.__user).group(1)
3131 def get_pusher_email(self):
3132 return self.__user
3134 def get_fromaddr(self, change=None):
3135 return self.__user
3138 class StashEnvironment(
3139 StashEnvironmentMixin,
3140 ProjectdescEnvironmentMixin,
3141 ConfigMaxlinesEnvironmentMixin,
3142 ComputeFQDNEnvironmentMixin,
3143 ConfigFilterLinesEnvironmentMixin,
3144 ConfigRecipientsEnvironmentMixin,
3145 ConfigRefFilterEnvironmentMixin,
3146 PusherDomainEnvironmentMixin,
3147 ConfigOptionsEnvironmentMixin,
3148 Environment,
3150 pass
3153 class GerritEnvironmentMixin(Environment):
3154 def __init__(self, project=None, submitter=None, update_method=None, **kw):
3155 super(GerritEnvironmentMixin, self).__init__(**kw)
3156 self.__project = project
3157 self.__submitter = submitter
3158 self.__update_method = update_method
3159 "Make an 'update_method' value available for templates."
3160 self.COMPUTED_KEYS += ['update_method']
3162 def get_repo_shortname(self):
3163 return self.__project
3165 def get_pusher(self):
3166 if self.__submitter:
3167 if self.__submitter.find('<') != -1:
3168 # Submitter has a configured email, we transformed
3169 # __submitter into an RFC 2822 string already.
3170 return re.match('(.*?)\s*<', self.__submitter).group(1)
3171 else:
3172 # Submitter has no configured email, it's just his name.
3173 return self.__submitter
3174 else:
3175 # If we arrive here, this means someone pushed "Submit" from
3176 # the gerrit web UI for the CR (or used one of the programmatic
3177 # APIs to do the same, such as gerrit review) and the
3178 # merge/push was done by the Gerrit user. It was technically
3179 # triggered by someone else, but sadly we have no way of
3180 # determining who that someone else is at this point.
3181 return 'Gerrit' # 'unknown user'?
3183 def get_pusher_email(self):
3184 if self.__submitter:
3185 return self.__submitter
3186 else:
3187 return super(GerritEnvironmentMixin, self).get_pusher_email()
3189 def get_fromaddr(self, change=None):
3190 if self.__submitter and self.__submitter.find('<') != -1:
3191 return self.__submitter
3192 else:
3193 return super(GerritEnvironmentMixin, self).get_fromaddr(change)
3195 def get_default_ref_ignore_regex(self):
3196 default = super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()
3197 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3199 def get_revision_recipients(self, revision):
3200 # Merge commits created by Gerrit when users hit "Submit this patchset"
3201 # in the Web UI (or do equivalently with REST APIs or the gerrit review
3202 # command) are not something users want to see an individual email for.
3203 # Filter them out.
3204 committer = read_git_output(['log', '--no-walk', '--format=%cN',
3205 revision.rev.sha1])
3206 if committer == 'Gerrit Code Review':
3207 return []
3208 else:
3209 return super(GerritEnvironmentMixin, self).get_revision_recipients(revision)
3211 def get_update_method(self):
3212 return self.__update_method
3215 class GerritEnvironment(
3216 GerritEnvironmentMixin,
3217 ProjectdescEnvironmentMixin,
3218 ConfigMaxlinesEnvironmentMixin,
3219 ComputeFQDNEnvironmentMixin,
3220 ConfigFilterLinesEnvironmentMixin,
3221 ConfigRecipientsEnvironmentMixin,
3222 ConfigRefFilterEnvironmentMixin,
3223 PusherDomainEnvironmentMixin,
3224 ConfigOptionsEnvironmentMixin,
3225 Environment,
3227 pass
3230 class Push(object):
3231 """Represent an entire push (i.e., a group of ReferenceChanges).
3233 It is easy to figure out what commits were added to a *branch* by
3234 a Reference change:
3236 git rev-list change.old..change.new
3238 or removed from a *branch*:
3240 git rev-list change.new..change.old
3242 But it is not quite so trivial to determine which entirely new
3243 commits were added to the *repository* by a push and which old
3244 commits were discarded by a push. A big part of the job of this
3245 class is to figure out these things, and to make sure that new
3246 commits are only detailed once even if they were added to multiple
3247 references.
3249 The first step is to determine the "other" references--those
3250 unaffected by the current push. They are computed by listing all
3251 references then removing any affected by this push. The results
3252 are stored in Push._other_ref_sha1s.
3254 The commits contained in the repository before this push were
3256 git rev-list other1 other2 other3 ... change1.old change2.old ...
3258 Where "changeN.old" is the old value of one of the references
3259 affected by this push.
3261 The commits contained in the repository after this push are
3263 git rev-list other1 other2 other3 ... change1.new change2.new ...
3265 The commits added by this push are the difference between these
3266 two sets, which can be written
3268 git rev-list \
3269 ^other1 ^other2 ... \
3270 ^change1.old ^change2.old ... \
3271 change1.new change2.new ...
3273 The commits removed by this push can be computed by
3275 git rev-list \
3276 ^other1 ^other2 ... \
3277 ^change1.new ^change2.new ... \
3278 change1.old change2.old ...
3280 The last point is that it is possible that other pushes are
3281 occurring simultaneously to this one, so reference values can
3282 change at any time. It is impossible to eliminate all race
3283 conditions, but we reduce the window of time during which problems
3284 can occur by translating reference names to SHA1s as soon as
3285 possible and working with SHA1s thereafter (because SHA1s are
3286 immutable)."""
3288 # A map {(changeclass, changetype): integer} specifying the order
3289 # that reference changes will be processed if multiple reference
3290 # changes are included in a single push. The order is significant
3291 # mostly because new commit notifications are threaded together
3292 # with the first reference change that includes the commit. The
3293 # following order thus causes commits to be grouped with branch
3294 # changes (as opposed to tag changes) if possible.
3295 SORT_ORDER = dict(
3296 (value, i) for (i, value) in enumerate([
3297 (BranchChange, 'update'),
3298 (BranchChange, 'create'),
3299 (AnnotatedTagChange, 'update'),
3300 (AnnotatedTagChange, 'create'),
3301 (NonAnnotatedTagChange, 'update'),
3302 (NonAnnotatedTagChange, 'create'),
3303 (BranchChange, 'delete'),
3304 (AnnotatedTagChange, 'delete'),
3305 (NonAnnotatedTagChange, 'delete'),
3306 (OtherReferenceChange, 'update'),
3307 (OtherReferenceChange, 'create'),
3308 (OtherReferenceChange, 'delete'),
3312 def __init__(self, environment, changes, ignore_other_refs=False):
3313 self.changes = sorted(changes, key=self._sort_key)
3314 self.__other_ref_sha1s = None
3315 self.__cached_commits_spec = {}
3316 self.environment = environment
3318 if ignore_other_refs:
3319 self.__other_ref_sha1s = set()
3321 @classmethod
3322 def _sort_key(klass, change):
3323 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3325 @property
3326 def _other_ref_sha1s(self):
3327 """The GitObjects referred to by references unaffected by this push.
3329 if self.__other_ref_sha1s is None:
3330 # The refnames being changed by this push:
3331 updated_refs = set(
3332 change.refname
3333 for change in self.changes
3336 # The SHA-1s of commits referred to by all references in this
3337 # repository *except* updated_refs:
3338 sha1s = set()
3339 fmt = (
3340 '%(objectname) %(objecttype) %(refname)\n'
3341 '%(*objectname) %(*objecttype) %(refname)'
3343 ref_filter_regex, is_inclusion_filter = \
3344 self.environment.get_ref_filter_regex()
3345 for line in read_git_lines(
3346 ['for-each-ref', '--format=%s' % (fmt,)]):
3347 (sha1, type, name) = line.split(' ', 2)
3348 if (sha1 and type == 'commit' and
3349 name not in updated_refs and
3350 include_ref(name, ref_filter_regex, is_inclusion_filter)):
3351 sha1s.add(sha1)
3353 self.__other_ref_sha1s = sha1s
3355 return self.__other_ref_sha1s
3357 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3358 """Get new or old SHA-1 from one or each of the changed refs.
3360 Return a list of SHA-1 commit identifier strings suitable as
3361 arguments to 'git rev-list' (or 'git log' or ...). The
3362 returned identifiers are either the old or new values from one
3363 or all of the changed references, depending on the values of
3364 new_or_old and reference_change.
3366 new_or_old is either the string 'new' or the string 'old'. If
3367 'new', the returned SHA-1 identifiers are the new values from
3368 each changed reference. If 'old', the SHA-1 identifiers are
3369 the old values from each changed reference.
3371 If reference_change is specified and not None, only the new or
3372 old reference from the specified reference is included in the
3373 return value.
3375 This function returns None if there are no matching revisions
3376 (e.g., because a branch was deleted and new_or_old is 'new').
3379 if not reference_change:
3380 incl_spec = sorted(
3381 getattr(change, new_or_old).sha1
3382 for change in self.changes
3383 if getattr(change, new_or_old)
3385 if not incl_spec:
3386 incl_spec = None
3387 elif not getattr(reference_change, new_or_old).commit_sha1:
3388 incl_spec = None
3389 else:
3390 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3391 return incl_spec
3393 def _get_commits_spec_excl(self, new_or_old):
3394 """Get exclusion revisions for determining new or discarded commits.
3396 Return a list of strings suitable as arguments to 'git
3397 rev-list' (or 'git log' or ...) that will exclude all
3398 commits that, depending on the value of new_or_old, were
3399 either previously in the repository (useful for determining
3400 which commits are new to the repository) or currently in the
3401 repository (useful for determining which commits were
3402 discarded from the repository).
3404 new_or_old is either the string 'new' or the string 'old'. If
3405 'new', the commits to be excluded are those that were in the
3406 repository before the push. If 'old', the commits to be
3407 excluded are those that are currently in the repository. """
3409 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3410 excl_revs = self._other_ref_sha1s.union(
3411 getattr(change, old_or_new).sha1
3412 for change in self.changes
3413 if getattr(change, old_or_new).type in ['commit', 'tag']
3415 return ['^' + sha1 for sha1 in sorted(excl_revs)]
3417 def get_commits_spec(self, new_or_old, reference_change=None):
3418 """Get rev-list arguments for added or discarded commits.
3420 Return a list of strings suitable as arguments to 'git
3421 rev-list' (or 'git log' or ...) that select those commits
3422 that, depending on the value of new_or_old, are either new to
3423 the repository or were discarded from the repository.
3425 new_or_old is either the string 'new' or the string 'old'. If
3426 'new', the returned list is used to select commits that are
3427 new to the repository. If 'old', the returned value is used
3428 to select the commits that have been discarded from the
3429 repository.
3431 If reference_change is specified and not None, the new or
3432 discarded commits are limited to those that are reachable from
3433 the new or old value of the specified reference.
3435 This function returns None if there are no added (or discarded)
3436 revisions.
3438 key = (new_or_old, reference_change)
3439 if key not in self.__cached_commits_spec:
3440 ret = self._get_commits_spec_incl(new_or_old, reference_change)
3441 if ret is not None:
3442 ret.extend(self._get_commits_spec_excl(new_or_old))
3443 self.__cached_commits_spec[key] = ret
3444 return self.__cached_commits_spec[key]
3446 def get_new_commits(self, reference_change=None):
3447 """Return a list of commits added by this push.
3449 Return a list of the object names of commits that were added
3450 by the part of this push represented by reference_change. If
3451 reference_change is None, then return a list of *all* commits
3452 added by this push."""
3454 spec = self.get_commits_spec('new', reference_change)
3455 return git_rev_list(spec)
3457 def get_discarded_commits(self, reference_change):
3458 """Return a list of commits discarded by this push.
3460 Return a list of the object names of commits that were
3461 entirely discarded from the repository by the part of this
3462 push represented by reference_change."""
3464 spec = self.get_commits_spec('old', reference_change)
3465 return git_rev_list(spec)
3467 def send_emails(self, mailer, body_filter=None):
3468 """Use send all of the notification emails needed for this push.
3470 Use send all of the notification emails (including reference
3471 change emails and commit emails) needed for this push. Send
3472 the emails using mailer. If body_filter is not None, then use
3473 it to filter the lines that are intended for the email
3474 body."""
3476 # The sha1s of commits that were introduced by this push.
3477 # They will be removed from this set as they are processed, to
3478 # guarantee that one (and only one) email is generated for
3479 # each new commit.
3480 unhandled_sha1s = set(self.get_new_commits())
3481 send_date = IncrementalDateTime()
3482 for change in self.changes:
3483 sha1s = []
3484 for sha1 in reversed(list(self.get_new_commits(change))):
3485 if sha1 in unhandled_sha1s:
3486 sha1s.append(sha1)
3487 unhandled_sha1s.remove(sha1)
3489 # Check if we've got anyone to send to
3490 if not change.recipients:
3491 change.environment.log_warning(
3492 '*** no recipients configured so no email will be sent\n'
3493 '*** for %r update %s->%s\n'
3494 % (change.refname, change.old.sha1, change.new.sha1,)
3496 else:
3497 if not change.environment.quiet:
3498 change.environment.log_msg(
3499 'Sending notification emails to: %s\n' % (change.recipients,))
3500 extra_values = {'send_date': next(send_date)}
3502 rev = change.send_single_combined_email(sha1s)
3503 if rev:
3504 mailer.send(
3505 change.generate_combined_email(self, rev, body_filter, extra_values),
3506 rev.recipients,
3508 # This change is now fully handled; no need to handle
3509 # individual revisions any further.
3510 continue
3511 else:
3512 mailer.send(
3513 change.generate_email(self, body_filter, extra_values),
3514 change.recipients,
3517 max_emails = change.environment.maxcommitemails
3518 if max_emails and len(sha1s) > max_emails:
3519 change.environment.log_warning(
3520 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3521 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3522 '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3524 return
3526 for (num, sha1) in enumerate(sha1s):
3527 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3528 if not rev.recipients and rev.cc_recipients:
3529 change.environment.log_msg('*** Replacing Cc: with To:\n')
3530 rev.recipients = rev.cc_recipients
3531 rev.cc_recipients = None
3532 if rev.recipients:
3533 extra_values = {'send_date': next(send_date)}
3534 mailer.send(
3535 rev.generate_email(self, body_filter, extra_values),
3536 rev.recipients,
3539 # Consistency check:
3540 if unhandled_sha1s:
3541 change.environment.log_error(
3542 'ERROR: No emails were sent for the following new commits:\n'
3543 ' %s\n'
3544 % ('\n '.join(sorted(unhandled_sha1s)),)
3548 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3549 does_match = bool(ref_filter_regex.search(refname))
3550 if is_inclusion_filter:
3551 return does_match
3552 else: # exclusion filter -- we include the ref if the regex doesn't match
3553 return not does_match
3556 def run_as_post_receive_hook(environment, mailer):
3557 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3558 changes = []
3559 for line in sys.stdin:
3560 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3561 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3562 continue
3563 changes.append(
3564 ReferenceChange.create(environment, oldrev, newrev, refname)
3566 if changes:
3567 push = Push(environment, changes)
3568 push.send_emails(mailer, body_filter=environment.filter_body)
3569 if hasattr(mailer, '__del__'):
3570 mailer.__del__()
3573 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3574 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3575 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3576 return
3577 changes = [
3578 ReferenceChange.create(
3579 environment,
3580 read_git_output(['rev-parse', '--verify', oldrev]),
3581 read_git_output(['rev-parse', '--verify', newrev]),
3582 refname,
3585 push = Push(environment, changes, force_send)
3586 push.send_emails(mailer, body_filter=environment.filter_body)
3587 if hasattr(mailer, '__del__'):
3588 mailer.__del__()
3591 def choose_mailer(config, environment):
3592 mailer = config.get('mailer', default='sendmail')
3594 if mailer == 'smtp':
3595 smtpserver = config.get('smtpserver', default='localhost')
3596 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3597 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3598 smtpencryption = config.get('smtpencryption', default='none')
3599 smtpuser = config.get('smtpuser', default='')
3600 smtppass = config.get('smtppass', default='')
3601 smtpcacerts = config.get('smtpcacerts', default='')
3602 mailer = SMTPMailer(
3603 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3604 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3605 smtpserverdebuglevel=smtpserverdebuglevel,
3606 smtpencryption=smtpencryption,
3607 smtpuser=smtpuser,
3608 smtppass=smtppass,
3609 smtpcacerts=smtpcacerts
3611 elif mailer == 'sendmail':
3612 command = config.get('sendmailcommand')
3613 if command:
3614 command = shlex.split(command)
3615 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
3616 else:
3617 environment.log_error(
3618 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3619 'please use one of "smtp" or "sendmail".\n'
3621 sys.exit(1)
3622 return mailer
3625 KNOWN_ENVIRONMENTS = {
3626 'generic': GenericEnvironmentMixin,
3627 'gitolite': GitoliteEnvironmentMixin,
3628 'stash': StashEnvironmentMixin,
3629 'gerrit': GerritEnvironmentMixin,
3633 def choose_environment(config, osenv=None, env=None, recipients=None,
3634 hook_info=None):
3635 if not osenv:
3636 osenv = os.environ
3638 environment_mixins = [
3639 ConfigRefFilterEnvironmentMixin,
3640 ProjectdescEnvironmentMixin,
3641 ConfigMaxlinesEnvironmentMixin,
3642 ComputeFQDNEnvironmentMixin,
3643 ConfigFilterLinesEnvironmentMixin,
3644 PusherDomainEnvironmentMixin,
3645 ConfigOptionsEnvironmentMixin,
3647 environment_kw = {
3648 'osenv': osenv,
3649 'config': config,
3652 if not env:
3653 env = config.get('environment')
3655 if not env:
3656 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3657 env = 'gitolite'
3658 else:
3659 env = 'generic'
3661 environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])
3663 if env == 'stash':
3664 environment_kw['user'] = hook_info['stash_user']
3665 environment_kw['repo'] = hook_info['stash_repo']
3666 elif env == 'gerrit':
3667 environment_kw['project'] = hook_info['project']
3668 environment_kw['submitter'] = hook_info['submitter']
3669 environment_kw['update_method'] = hook_info['update_method']
3671 if recipients:
3672 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
3673 environment_kw['refchange_recipients'] = recipients
3674 environment_kw['announce_recipients'] = recipients
3675 environment_kw['revision_recipients'] = recipients
3676 environment_kw['scancommitforcc'] = config.get('scancommitforcc')
3677 else:
3678 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
3680 environment_klass = type(
3681 'EffectiveEnvironment',
3682 tuple(environment_mixins) + (Environment,),
3685 return environment_klass(**environment_kw)
3688 def get_version():
3689 oldcwd = os.getcwd()
3690 try:
3691 try:
3692 os.chdir(os.path.dirname(os.path.realpath(__file__)))
3693 git_version = read_git_output(['describe', '--tags', 'HEAD'])
3694 if git_version == __version__:
3695 return git_version
3696 else:
3697 return '%s (%s)' % (__version__, git_version)
3698 except:
3699 pass
3700 finally:
3701 os.chdir(oldcwd)
3702 return __version__
3705 def compute_gerrit_options(options, args, required_gerrit_options):
3706 if None in required_gerrit_options:
3707 raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3708 "and --project; or none of them.")
3710 if options.environment not in (None, 'gerrit'):
3711 raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3712 "--newrev, --refname, and --project")
3713 options.environment = 'gerrit'
3715 if args:
3716 raise SystemExit("Error: Positional parameters not allowed with "
3717 "--oldrev, --newrev, and --refname.")
3719 # Gerrit oddly omits 'refs/heads/' in the refname when calling
3720 # ref-updated hook; put it back.
3721 git_dir = get_git_dir()
3722 if (not os.path.exists(os.path.join(git_dir, options.refname)) and
3723 os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3724 options.refname))):
3725 options.refname = 'refs/heads/' + options.refname
3727 # Convert each string option unicode for Python3.
3728 if PYTHON3:
3729 opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3730 'project', 'submitter', 'stash-user', 'stash-repo']
3731 for opt in opts:
3732 if not hasattr(options, opt):
3733 continue
3734 obj = getattr(options, opt)
3735 if obj:
3736 enc = obj.encode('utf-8', 'surrogateescape')
3737 dec = enc.decode('utf-8', 'replace')
3738 setattr(options, opt, dec)
3740 # New revisions can appear in a gerrit repository either due to someone
3741 # pushing directly (in which case options.submitter will be set), or they
3742 # can press "Submit this patchset" in the web UI for some CR (in which
3743 # case options.submitter will not be set and gerrit will not have provided
3744 # us the information about who pressed the button).
3746 # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3747 # gerrit review command in with "Submit this patchset" button, since they
3748 # have the same effect.
3749 if options.submitter:
3750 update_method = 'pushed'
3751 # The submitter argument is almost an RFC 2822 email address; change it
3752 # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3753 options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3754 else:
3755 update_method = 'submitted'
3756 # Gerrit knew who submitted this patchset, but threw that information
3757 # away when it invoked this hook. However, *IF* Gerrit created a
3758 # merge to bring the patchset in (project 'Submit Type' is either
3759 # "Always Merge", or is "Merge if Necessary" and happens to be
3760 # necessary for this particular CR), then it will have the committer
3761 # of that merge be 'Gerrit Code Review' and the author will be the
3762 # person who requested the submission of the CR. Since this is fairly
3763 # likely for most gerrit installations (of a reasonable size), it's
3764 # worth the extra effort to try to determine the actual submitter.
3765 rev_info = read_git_lines(['log', '--no-walk', '--merges',
3766 '--format=%cN%n%aN <%aE>', options.newrev])
3767 if rev_info and rev_info[0] == 'Gerrit Code Review':
3768 options.submitter = rev_info[1]
3770 # We pass back refname, oldrev, newrev as args because then the
3771 # gerrit ref-updated hook is much like the git update hook
3772 return (options,
3773 [options.refname, options.oldrev, options.newrev],
3774 {'project': options.project, 'submitter': options.submitter,
3775 'update_method': update_method})
3778 def check_hook_specific_args(options, args):
3779 # First check for stash arguments
3780 if (options.stash_user is None) != (options.stash_repo is None):
3781 raise SystemExit("Error: Specify both of --stash-user and "
3782 "--stash-repo or neither.")
3783 if options.stash_user:
3784 options.environment = 'stash'
3785 return options, args, {'stash_user': options.stash_user,
3786 'stash_repo': options.stash_repo}
3788 # Finally, check for gerrit specific arguments
3789 required_gerrit_options = (options.oldrev, options.newrev, options.refname,
3790 options.project)
3791 if required_gerrit_options != (None,) * 4:
3792 return compute_gerrit_options(options, args, required_gerrit_options)
3794 # No special options in use, just return what we started with
3795 return options, args, {}
3798 def main(args):
3799 parser = optparse.OptionParser(
3800 description=__doc__,
3801 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
3804 parser.add_option(
3805 '--environment', '--env', action='store', type='choice',
3806 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
3807 help=(
3808 'Choose type of environment is in use. Default is taken from '
3809 'multimailhook.environment if set; otherwise "generic".'
3812 parser.add_option(
3813 '--stdout', action='store_true', default=False,
3814 help='Output emails to stdout rather than sending them.',
3816 parser.add_option(
3817 '--recipients', action='store', default=None,
3818 help='Set list of email recipients for all types of emails.',
3820 parser.add_option(
3821 '--show-env', action='store_true', default=False,
3822 help=(
3823 'Write to stderr the values determined for the environment '
3824 '(intended for debugging purposes).'
3827 parser.add_option(
3828 '--force-send', action='store_true', default=False,
3829 help=(
3830 'Force sending refchange email when using as an update hook. '
3831 'This is useful to work around the unreliable new commits '
3832 'detection in this mode.'
3835 parser.add_option(
3836 '-c', metavar="<name>=<value>", action='append',
3837 help=(
3838 'Pass a configuration parameter through to git. The value given '
3839 'will override values from configuration files. See the -c option '
3840 'of git(1) for more details. (Only works with git >= 1.7.3)'
3843 parser.add_option(
3844 '--version', '-v', action='store_true', default=False,
3845 help=(
3846 "Display git-multimail's version"
3849 # The following options permit this script to be run as a gerrit
3850 # ref-updated hook. See e.g.
3851 # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
3852 # We suppress help for these items, since these are specific to gerrit,
3853 # and we don't want users directly using them any way other than how the
3854 # gerrit ref-updated hook is called.
3855 parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
3856 parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
3857 parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
3858 parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
3859 parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
3861 # The following allow this to be run as a stash asynchronous post-receive
3862 # hook (almost identical to a git post-receive hook but triggered also for
3863 # merges of pull requests from the UI). We suppress help for these items,
3864 # since these are specific to stash.
3865 parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
3866 parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
3868 (options, args) = parser.parse_args(args)
3869 (options, args, hook_info) = check_hook_specific_args(options, args)
3871 if options.version:
3872 sys.stdout.write('git-multimail version ' + get_version() + '\n')
3873 return
3875 if options.c:
3876 Config.add_config_parameters(options.c)
3878 config = Config('multimailhook')
3880 try:
3881 environment = choose_environment(
3882 config, osenv=os.environ,
3883 env=options.environment,
3884 recipients=options.recipients,
3885 hook_info=hook_info,
3888 if options.show_env:
3889 sys.stderr.write('Environment values:\n')
3890 for (k, v) in sorted(environment.get_values().items()):
3891 sys.stderr.write(' %s : %r\n' % (k, v))
3892 sys.stderr.write('\n')
3894 if options.stdout or environment.stdout:
3895 mailer = OutputMailer(sys.stdout)
3896 else:
3897 mailer = choose_mailer(config, environment)
3899 # Dual mode: if arguments were specified on the command line, run
3900 # like an update hook; otherwise, run as a post-receive hook.
3901 if args:
3902 if len(args) != 3:
3903 parser.error('Need zero or three non-option arguments')
3904 (refname, oldrev, newrev) = args
3905 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3906 else:
3907 run_as_post_receive_hook(environment, mailer)
3908 except ConfigurationException:
3909 sys.exit(sys.exc_info()[1])
3910 except Exception:
3911 t, e, tb = sys.exc_info()
3912 import traceback
3913 sys.stdout.write('\n')
3914 sys.stdout.write('Exception \'' + t.__name__ +
3915 '\' raised. Please report this as a bug to\n')
3916 sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')
3917 sys.stdout.write('with the information below:\n\n')
3918 sys.stdout.write('git-multimail version ' + get_version() + '\n')
3919 sys.stdout.write('Python version ' + sys.version + '\n')
3920 traceback.print_exc(file=sys.stdout)
3921 sys.exit(1)
3923 if __name__ == '__main__':
3924 main(sys.argv[1:])