git-multimail: update to release 1.4.0
[git.git] / contrib / hooks / multimail / git_multimail.py
blobc7f86403cf0750289b08be6d7d66180c78242f0c
1 #! /usr/bin/env python
3 __version__ = '1.4.0'
5 # Copyright (c) 2015-2016 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 logging
60 import smtplib
61 try:
62 import ssl
63 except ImportError:
64 # Python < 2.6 do not have ssl, but that's OK if we don't use it.
65 pass
66 import time
67 import cgi
69 PYTHON3 = sys.version_info >= (3, 0)
71 if sys.version_info <= (2, 5):
72 def all(iterable):
73 for element in iterable:
74 if not element:
75 return False
76 return True
79 def is_ascii(s):
80 return all(ord(c) < 128 and ord(c) > 0 for c in s)
83 if PYTHON3:
84 def is_string(s):
85 return isinstance(s, str)
87 def str_to_bytes(s):
88 return s.encode(ENCODING)
90 def bytes_to_str(s, errors='strict'):
91 return s.decode(ENCODING, errors)
93 unicode = str
95 def write_str(f, msg):
96 # Try outputing with the default encoding. If it fails,
97 # try UTF-8.
98 try:
99 f.buffer.write(msg.encode(sys.getdefaultencoding()))
100 except UnicodeEncodeError:
101 f.buffer.write(msg.encode(ENCODING))
103 def read_line(f):
104 # Try reading with the default encoding. If it fails,
105 # try UTF-8.
106 out = f.buffer.readline()
107 try:
108 return out.decode(sys.getdefaultencoding())
109 except UnicodeEncodeError:
110 return out.decode(ENCODING)
111 else:
112 def is_string(s):
113 try:
114 return isinstance(s, basestring)
115 except NameError: # Silence Pyflakes warning
116 raise
118 def str_to_bytes(s):
119 return s
121 def bytes_to_str(s, errors='strict'):
122 return s
124 def write_str(f, msg):
125 f.write(msg)
127 def read_line(f):
128 return f.readline()
130 def next(it):
131 return it.next()
134 try:
135 from email.charset import Charset
136 from email.utils import make_msgid
137 from email.utils import getaddresses
138 from email.utils import formataddr
139 from email.utils import formatdate
140 from email.header import Header
141 except ImportError:
142 # Prior to Python 2.5, the email module used different names:
143 from email.Charset import Charset
144 from email.Utils import make_msgid
145 from email.Utils import getaddresses
146 from email.Utils import formataddr
147 from email.Utils import formatdate
148 from email.Header import Header
151 DEBUG = False
153 ZEROS = '0' * 40
154 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
155 LOGEND = '-----------------------------------------------------------------------\n'
157 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
159 # It is assumed in many places that the encoding is uniformly UTF-8,
160 # so changing these constants is unsupported. But define them here
161 # anyway, to make it easier to find (at least most of) the places
162 # where the encoding is important.
163 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
166 REF_CREATED_SUBJECT_TEMPLATE = (
167 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
168 ' (now %(newrev_short)s)'
170 REF_UPDATED_SUBJECT_TEMPLATE = (
171 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
172 ' (%(oldrev_short)s -> %(newrev_short)s)'
174 REF_DELETED_SUBJECT_TEMPLATE = (
175 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
176 ' (was %(oldrev_short)s)'
179 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
180 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
183 REFCHANGE_HEADER_TEMPLATE = """\
184 Date: %(send_date)s
185 To: %(recipients)s
186 Subject: %(subject)s
187 MIME-Version: 1.0
188 Content-Type: text/%(contenttype)s; charset=%(charset)s
189 Content-Transfer-Encoding: 8bit
190 Message-ID: %(msgid)s
191 From: %(fromaddr)s
192 Reply-To: %(reply_to)s
193 X-Git-Host: %(fqdn)s
194 X-Git-Repo: %(repo_shortname)s
195 X-Git-Refname: %(refname)s
196 X-Git-Reftype: %(refname_type)s
197 X-Git-Oldrev: %(oldrev)s
198 X-Git-Newrev: %(newrev)s
199 X-Git-NotificationType: ref_changed
200 X-Git-Multimail-Version: %(multimail_version)s
201 Auto-Submitted: auto-generated
204 REFCHANGE_INTRO_TEMPLATE = """\
205 This is an automated email from the git hooks/post-receive script.
207 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
208 in repository %(repo_shortname)s.
213 FOOTER_TEMPLATE = """\
215 -- \n\
216 To stop receiving notification emails like this one, please contact
217 %(administrator)s.
221 REWIND_ONLY_TEMPLATE = """\
222 This update removed existing revisions from the reference, leaving the
223 reference pointing at a previous point in the repository history.
225 * -- * -- N %(refname)s (%(newrev_short)s)
227 O -- O -- O (%(oldrev_short)s)
229 Any revisions marked "omit" are not gone; other references still
230 refer to them. Any revisions marked "discard" are gone forever.
234 NON_FF_TEMPLATE = """\
235 This update added new revisions after undoing existing revisions.
236 That is to say, some revisions that were in the old version of the
237 %(refname_type)s are not in the new version. This situation occurs
238 when a user --force pushes a change and generates a repository
239 containing something like this:
241 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
243 N -- N -- N %(refname)s (%(newrev_short)s)
245 You should already have received notification emails for all of the O
246 revisions, and so the following emails describe only the N revisions
247 from the common base, B.
249 Any revisions marked "omit" are not gone; other references still
250 refer to them. Any revisions marked "discard" are gone forever.
254 NO_NEW_REVISIONS_TEMPLATE = """\
255 No new revisions were added by this update.
259 DISCARDED_REVISIONS_TEMPLATE = """\
260 This change permanently discards the following revisions:
264 NO_DISCARDED_REVISIONS_TEMPLATE = """\
265 The revisions that were on this %(refname_type)s are still contained in
266 other references; therefore, this change does not discard any commits
267 from the repository.
271 NEW_REVISIONS_TEMPLATE = """\
272 The %(tot)s revisions listed above as "new" are entirely new to this
273 repository and will be described in separate emails. The revisions
274 listed as "add" were already present in the repository and have only
275 been added to this reference.
280 TAG_CREATED_TEMPLATE = """\
281 at %(newrev_short)-8s (%(newrev_type)s)
285 TAG_UPDATED_TEMPLATE = """\
286 *** WARNING: tag %(short_refname)s was modified! ***
288 from %(oldrev_short)-8s (%(oldrev_type)s)
289 to %(newrev_short)-8s (%(newrev_type)s)
293 TAG_DELETED_TEMPLATE = """\
294 *** WARNING: tag %(short_refname)s was deleted! ***
299 # The template used in summary tables. It looks best if this uses the
300 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
301 BRIEF_SUMMARY_TEMPLATE = """\
302 %(action)8s %(rev_short)-8s %(text)s
306 NON_COMMIT_UPDATE_TEMPLATE = """\
307 This is an unusual reference change because the reference did not
308 refer to a commit either before or after the change. We do not know
309 how to provide full information about this reference change.
313 REVISION_HEADER_TEMPLATE = """\
314 Date: %(send_date)s
315 To: %(recipients)s
316 Cc: %(cc_recipients)s
317 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
318 MIME-Version: 1.0
319 Content-Type: text/%(contenttype)s; charset=%(charset)s
320 Content-Transfer-Encoding: 8bit
321 From: %(fromaddr)s
322 Reply-To: %(reply_to)s
323 In-Reply-To: %(reply_to_msgid)s
324 References: %(reply_to_msgid)s
325 X-Git-Host: %(fqdn)s
326 X-Git-Repo: %(repo_shortname)s
327 X-Git-Refname: %(refname)s
328 X-Git-Reftype: %(refname_type)s
329 X-Git-Rev: %(rev)s
330 X-Git-NotificationType: diff
331 X-Git-Multimail-Version: %(multimail_version)s
332 Auto-Submitted: auto-generated
335 REVISION_INTRO_TEMPLATE = """\
336 This is an automated email from the git hooks/post-receive script.
338 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
339 in repository %(repo_shortname)s.
343 LINK_TEXT_TEMPLATE = """\
344 View the commit online:
345 %(browse_url)s
349 LINK_HTML_TEMPLATE = """\
350 <p><a href="%(browse_url)s">View the commit online</a>.</p>
354 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
357 # Combined, meaning refchange+revision email (for single-commit additions)
358 COMBINED_HEADER_TEMPLATE = """\
359 Date: %(send_date)s
360 To: %(recipients)s
361 Subject: %(subject)s
362 MIME-Version: 1.0
363 Content-Type: text/%(contenttype)s; charset=%(charset)s
364 Content-Transfer-Encoding: 8bit
365 Message-ID: %(msgid)s
366 From: %(fromaddr)s
367 Reply-To: %(reply_to)s
368 X-Git-Host: %(fqdn)s
369 X-Git-Repo: %(repo_shortname)s
370 X-Git-Refname: %(refname)s
371 X-Git-Reftype: %(refname_type)s
372 X-Git-Oldrev: %(oldrev)s
373 X-Git-Newrev: %(newrev)s
374 X-Git-Rev: %(rev)s
375 X-Git-NotificationType: ref_changed_plus_diff
376 X-Git-Multimail-Version: %(multimail_version)s
377 Auto-Submitted: auto-generated
380 COMBINED_INTRO_TEMPLATE = """\
381 This is an automated email from the git hooks/post-receive script.
383 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
384 in repository %(repo_shortname)s.
388 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
391 class CommandError(Exception):
392 def __init__(self, cmd, retcode):
393 self.cmd = cmd
394 self.retcode = retcode
395 Exception.__init__(
396 self,
397 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
401 class ConfigurationException(Exception):
402 pass
405 # The "git" program (this could be changed to include a full path):
406 GIT_EXECUTABLE = 'git'
409 # How "git" should be invoked (including global arguments), as a list
410 # of words. This variable is usually initialized automatically by
411 # read_git_output() via choose_git_command(), but if a value is set
412 # here then it will be used unconditionally.
413 GIT_CMD = None
416 def choose_git_command():
417 """Decide how to invoke git, and record the choice in GIT_CMD."""
419 global GIT_CMD
421 if GIT_CMD is None:
422 try:
423 # Check to see whether the "-c" option is accepted (it was
424 # only added in Git 1.7.2). We don't actually use the
425 # output of "git --version", though if we needed more
426 # specific version information this would be the place to
427 # do it.
428 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
429 read_output(cmd)
430 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
431 except CommandError:
432 GIT_CMD = [GIT_EXECUTABLE]
435 def read_git_output(args, input=None, keepends=False, **kw):
436 """Read the output of a Git command."""
438 if GIT_CMD is None:
439 choose_git_command()
441 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
444 def read_output(cmd, input=None, keepends=False, **kw):
445 if input:
446 stdin = subprocess.PIPE
447 input = str_to_bytes(input)
448 else:
449 stdin = None
450 errors = 'strict'
451 if 'errors' in kw:
452 errors = kw['errors']
453 del kw['errors']
454 p = subprocess.Popen(
455 tuple(str_to_bytes(w) for w in cmd),
456 stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
458 (out, err) = p.communicate(input)
459 out = bytes_to_str(out, errors=errors)
460 retcode = p.wait()
461 if retcode:
462 raise CommandError(cmd, retcode)
463 if not keepends:
464 out = out.rstrip('\n\r')
465 return out
468 def read_git_lines(args, keepends=False, **kw):
469 """Return the lines output by Git command.
471 Return as single lines, with newlines stripped off."""
473 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
476 def git_rev_list_ish(cmd, spec, args=None, **kw):
477 """Common functionality for invoking a 'git rev-list'-like command.
479 Parameters:
480 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
481 * spec is a list of revision arguments to pass to the named
482 command. If None, this function returns an empty list.
483 * args is a list of extra arguments passed to the named command.
484 * All other keyword arguments (if any) are passed to the
485 underlying read_git_lines() function.
487 Return the output of the Git command in the form of a list, one
488 entry per output line.
490 if spec is None:
491 return []
492 if args is None:
493 args = []
494 args = [cmd, '--stdin'] + args
495 spec_stdin = ''.join(s + '\n' for s in spec)
496 return read_git_lines(args, input=spec_stdin, **kw)
499 def git_rev_list(spec, **kw):
500 """Run 'git rev-list' with the given list of revision arguments.
502 See git_rev_list_ish() for parameter and return value
503 documentation.
505 return git_rev_list_ish('rev-list', spec, **kw)
508 def git_log(spec, **kw):
509 """Run 'git log' with the given list of revision arguments.
511 See git_rev_list_ish() for parameter and return value
512 documentation.
514 return git_rev_list_ish('log', spec, **kw)
517 def header_encode(text, header_name=None):
518 """Encode and line-wrap the value of an email header field."""
520 # Convert to unicode, if required.
521 if not isinstance(text, unicode):
522 text = unicode(text, 'utf-8')
524 if is_ascii(text):
525 charset = 'ascii'
526 else:
527 charset = 'utf-8'
529 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
532 def addr_header_encode(text, header_name=None):
533 """Encode and line-wrap the value of an email header field containing
534 email addresses."""
536 # Convert to unicode, if required.
537 if not isinstance(text, unicode):
538 text = unicode(text, 'utf-8')
540 text = ', '.join(
541 formataddr((header_encode(name), emailaddr))
542 for name, emailaddr in getaddresses([text])
545 if is_ascii(text):
546 charset = 'ascii'
547 else:
548 charset = 'utf-8'
550 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
553 class Config(object):
554 def __init__(self, section, git_config=None):
555 """Represent a section of the git configuration.
557 If git_config is specified, it is passed to "git config" in
558 the GIT_CONFIG environment variable, meaning that "git config"
559 will read the specified path rather than the Git default
560 config paths."""
562 self.section = section
563 if git_config:
564 self.env = os.environ.copy()
565 self.env['GIT_CONFIG'] = git_config
566 else:
567 self.env = None
569 @staticmethod
570 def _split(s):
571 """Split NUL-terminated values."""
573 words = s.split('\0')
574 assert words[-1] == ''
575 return words[:-1]
577 @staticmethod
578 def add_config_parameters(c):
579 """Add configuration parameters to Git.
581 c is either an str or a list of str, each element being of the
582 form 'var=val' or 'var', with the same syntax and meaning as
583 the argument of 'git -c var=val'.
585 if isinstance(c, str):
586 c = (c,)
587 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
588 if parameters:
589 parameters += ' '
590 # git expects GIT_CONFIG_PARAMETERS to be of the form
591 # "'name1=value1' 'name2=value2' 'name3=value3'"
592 # including everything inside the double quotes (but not the double
593 # quotes themselves). Spacing is critical. Also, if a value contains
594 # a literal single quote that quote must be represented using the
595 # four character sequence: '\''
596 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
597 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
599 def get(self, name, default=None):
600 try:
601 values = self._split(read_git_output(
602 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
603 env=self.env, keepends=True,
605 assert len(values) == 1
606 return values[0]
607 except CommandError:
608 return default
610 def get_bool(self, name, default=None):
611 try:
612 value = read_git_output(
613 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
614 env=self.env,
616 except CommandError:
617 return default
618 return value == 'true'
620 def get_all(self, name, default=None):
621 """Read a (possibly multivalued) setting from the configuration.
623 Return the result as a list of values, or default if the name
624 is unset."""
626 try:
627 return self._split(read_git_output(
628 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
629 env=self.env, keepends=True,
631 except CommandError:
632 t, e, traceback = sys.exc_info()
633 if e.retcode == 1:
634 # "the section or key is invalid"; i.e., there is no
635 # value for the specified key.
636 return default
637 else:
638 raise
640 def set(self, name, value):
641 read_git_output(
642 ['config', '%s.%s' % (self.section, name), value],
643 env=self.env,
646 def add(self, name, value):
647 read_git_output(
648 ['config', '--add', '%s.%s' % (self.section, name), value],
649 env=self.env,
652 def __contains__(self, name):
653 return self.get_all(name, default=None) is not None
655 # We don't use this method anymore internally, but keep it here in
656 # case somebody is calling it from their own code:
657 def has_key(self, name):
658 return name in self
660 def unset_all(self, name):
661 try:
662 read_git_output(
663 ['config', '--unset-all', '%s.%s' % (self.section, name)],
664 env=self.env,
666 except CommandError:
667 t, e, traceback = sys.exc_info()
668 if e.retcode == 5:
669 # The name doesn't exist, which is what we wanted anyway...
670 pass
671 else:
672 raise
674 def set_recipients(self, name, value):
675 self.unset_all(name)
676 for pair in getaddresses([value]):
677 self.add(name, formataddr(pair))
680 def generate_summaries(*log_args):
681 """Generate a brief summary for each revision requested.
683 log_args are strings that will be passed directly to "git log" as
684 revision selectors. Iterate over (sha1_short, subject) for each
685 commit specified by log_args (subject is the first line of the
686 commit message as a string without EOLs)."""
688 cmd = [
689 'log', '--abbrev', '--format=%h %s',
690 ] + list(log_args) + ['--']
691 for line in read_git_lines(cmd):
692 yield tuple(line.split(' ', 1))
695 def limit_lines(lines, max_lines):
696 for (index, line) in enumerate(lines):
697 if index < max_lines:
698 yield line
700 if index >= max_lines:
701 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
704 def limit_linelength(lines, max_linelength):
705 for line in lines:
706 # Don't forget that lines always include a trailing newline.
707 if len(line) > max_linelength + 1:
708 line = line[:max_linelength - 7] + ' [...]\n'
709 yield line
712 class CommitSet(object):
713 """A (constant) set of object names.
715 The set should be initialized with full SHA1 object names. The
716 __contains__() method returns True iff its argument is an
717 abbreviation of any the names in the set."""
719 def __init__(self, names):
720 self._names = sorted(names)
722 def __len__(self):
723 return len(self._names)
725 def __contains__(self, sha1_abbrev):
726 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
728 i = bisect.bisect_left(self._names, sha1_abbrev)
729 return i < len(self) and self._names[i].startswith(sha1_abbrev)
732 class GitObject(object):
733 def __init__(self, sha1, type=None):
734 if sha1 == ZEROS:
735 self.sha1 = self.type = self.commit_sha1 = None
736 else:
737 self.sha1 = sha1
738 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
740 if self.type == 'commit':
741 self.commit_sha1 = self.sha1
742 elif self.type == 'tag':
743 try:
744 self.commit_sha1 = read_git_output(
745 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
747 except CommandError:
748 # Cannot deref tag to determine commit_sha1
749 self.commit_sha1 = None
750 else:
751 self.commit_sha1 = None
753 self.short = read_git_output(['rev-parse', '--short', sha1])
755 def get_summary(self):
756 """Return (sha1_short, subject) for this commit."""
758 if not self.sha1:
759 raise ValueError('Empty commit has no summary')
761 return next(iter(generate_summaries('--no-walk', self.sha1)))
763 def __eq__(self, other):
764 return isinstance(other, GitObject) and self.sha1 == other.sha1
766 def __hash__(self):
767 return hash(self.sha1)
769 def __nonzero__(self):
770 return bool(self.sha1)
772 def __bool__(self):
773 """Python 2 backward compatibility"""
774 return self.__nonzero__()
776 def __str__(self):
777 return self.sha1 or ZEROS
780 class Change(object):
781 """A Change that has been made to the Git repository.
783 Abstract class from which both Revisions and ReferenceChanges are
784 derived. A Change knows how to generate a notification email
785 describing itself."""
787 def __init__(self, environment):
788 self.environment = environment
789 self._values = None
790 self._contains_html_diff = False
792 def _contains_diff(self):
793 # We do contain a diff, should it be rendered in HTML?
794 if self.environment.commit_email_format == "html":
795 self._contains_html_diff = True
797 def _compute_values(self):
798 """Return a dictionary {keyword: expansion} for this Change.
800 Derived classes overload this method to add more entries to
801 the return value. This method is used internally by
802 get_values(). The return value should always be a new
803 dictionary."""
805 values = self.environment.get_values()
806 fromaddr = self.environment.get_fromaddr(change=self)
807 if fromaddr is not None:
808 values['fromaddr'] = fromaddr
809 values['multimail_version'] = get_version()
810 return values
812 # Aliases usable in template strings. Tuple of pairs (destination,
813 # source).
814 VALUES_ALIAS = (
815 ("id", "newrev"),
818 def get_values(self, **extra_values):
819 """Return a dictionary {keyword: expansion} for this Change.
821 Return a dictionary mapping keywords to the values that they
822 should be expanded to for this Change (used when interpolating
823 template strings). If any keyword arguments are supplied, add
824 those to the return value as well. The return value is always
825 a new dictionary."""
827 if self._values is None:
828 self._values = self._compute_values()
830 values = self._values.copy()
831 if extra_values:
832 values.update(extra_values)
834 for alias, val in self.VALUES_ALIAS:
835 values[alias] = values[val]
836 return values
838 def expand(self, template, **extra_values):
839 """Expand template.
841 Expand the template (which should be a string) using string
842 interpolation of the values for this Change. If any keyword
843 arguments are provided, also include those in the keywords
844 available for interpolation."""
846 return template % self.get_values(**extra_values)
848 def expand_lines(self, template, html_escape_val=False, **extra_values):
849 """Break template into lines and expand each line."""
851 values = self.get_values(**extra_values)
852 if html_escape_val:
853 for k in values:
854 if is_string(values[k]):
855 values[k] = cgi.escape(values[k], True)
856 for line in template.splitlines(True):
857 yield line % values
859 def expand_header_lines(self, template, **extra_values):
860 """Break template into lines and expand each line as an RFC 2822 header.
862 Encode values and split up lines that are too long. Silently
863 skip lines that contain references to unknown variables."""
865 values = self.get_values(**extra_values)
866 if self._contains_html_diff:
867 self._content_type = 'html'
868 else:
869 self._content_type = 'plain'
870 values['contenttype'] = self._content_type
872 for line in template.splitlines():
873 (name, value) = line.split(': ', 1)
875 try:
876 value = value % values
877 except KeyError:
878 t, e, traceback = sys.exc_info()
879 if DEBUG:
880 self.environment.log_warning(
881 'Warning: unknown variable %r in the following line; line skipped:\n'
882 ' %s\n'
883 % (e.args[0], line,)
885 else:
886 if name.lower() in ADDR_HEADERS:
887 value = addr_header_encode(value, name)
888 else:
889 value = header_encode(value, name)
890 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
891 yield splitline
893 def generate_email_header(self):
894 """Generate the RFC 2822 email headers for this Change, a line at a time.
896 The output should not include the trailing blank line."""
898 raise NotImplementedError()
900 def generate_browse_link(self, base_url):
901 """Generate a link to an online repository browser."""
902 return iter(())
904 def generate_email_intro(self, html_escape_val=False):
905 """Generate the email intro for this Change, a line at a time.
907 The output will be used as the standard boilerplate at the top
908 of the email body."""
910 raise NotImplementedError()
912 def generate_email_body(self):
913 """Generate the main part of the email body, a line at a time.
915 The text in the body might be truncated after a specified
916 number of lines (see multimailhook.emailmaxlines)."""
918 raise NotImplementedError()
920 def generate_email_footer(self, html_escape_val):
921 """Generate the footer of the email, a line at a time.
923 The footer is always included, irrespective of
924 multimailhook.emailmaxlines."""
926 raise NotImplementedError()
928 def _wrap_for_html(self, lines):
929 """Wrap the lines in HTML <pre> tag when using HTML format.
931 Escape special HTML characters and add <pre> and </pre> tags around
932 the given lines if we should be generating HTML as indicated by
933 self._contains_html_diff being set to true.
935 if self._contains_html_diff:
936 yield "<pre style='margin:0'>\n"
938 for line in lines:
939 yield cgi.escape(line)
941 yield '</pre>\n'
942 else:
943 for line in lines:
944 yield line
946 def generate_email(self, push, body_filter=None, extra_header_values={}):
947 """Generate an email describing this change.
949 Iterate over the lines (including the header lines) of an
950 email describing this change. If body_filter is not None,
951 then use it to filter the lines that are intended for the
952 email body.
954 The extra_header_values field is received as a dict and not as
955 **kwargs, to allow passing other keyword arguments in the
956 future (e.g. passing extra values to generate_email_intro()"""
958 for line in self.generate_email_header(**extra_header_values):
959 yield line
960 yield '\n'
961 html_escape_val = (self.environment.html_in_intro and
962 self._contains_html_diff)
963 intro = self.generate_email_intro(html_escape_val)
964 if not self.environment.html_in_intro:
965 intro = self._wrap_for_html(intro)
966 for line in intro:
967 yield line
969 if self.environment.commitBrowseURL:
970 for line in self.generate_browse_link(self.environment.commitBrowseURL):
971 yield line
973 body = self.generate_email_body(push)
974 if body_filter is not None:
975 body = body_filter(body)
977 diff_started = False
978 if self._contains_html_diff:
979 # "white-space: pre" is the default, but we need to
980 # specify it again in case the message is viewed in a
981 # webmail which wraps it in an element setting white-space
982 # to something else (Zimbra does this and sets
983 # white-space: pre-line).
984 yield '<pre style="white-space: pre; background: #F8F8F8">'
985 for line in body:
986 if self._contains_html_diff:
987 # This is very, very naive. It would be much better to really
988 # parse the diff, i.e. look at how many lines do we have in
989 # the hunk headers instead of blindly highlighting everything
990 # that looks like it might be part of a diff.
991 bgcolor = ''
992 fgcolor = ''
993 if line.startswith('--- a/'):
994 diff_started = True
995 bgcolor = 'e0e0ff'
996 elif line.startswith('diff ') or line.startswith('index '):
997 diff_started = True
998 fgcolor = '808080'
999 elif diff_started:
1000 if line.startswith('+++ '):
1001 bgcolor = 'e0e0ff'
1002 elif line.startswith('@@'):
1003 bgcolor = 'e0e0e0'
1004 elif line.startswith('+'):
1005 bgcolor = 'e0ffe0'
1006 elif line.startswith('-'):
1007 bgcolor = 'ffe0e0'
1008 elif line.startswith('commit '):
1009 fgcolor = '808000'
1010 elif line.startswith(' '):
1011 fgcolor = '404040'
1013 # Chop the trailing LF, we don't want it inside <pre>.
1014 line = cgi.escape(line[:-1])
1016 if bgcolor or fgcolor:
1017 style = 'display:block; white-space:pre;'
1018 if bgcolor:
1019 style += 'background:#' + bgcolor + ';'
1020 if fgcolor:
1021 style += 'color:#' + fgcolor + ';'
1022 # Use a <span style='display:block> to color the
1023 # whole line. The newline must be inside the span
1024 # to display properly both in Firefox and in
1025 # text-based browser.
1026 line = "<span style='%s'>%s\n</span>" % (style, line)
1027 else:
1028 line = line + '\n'
1030 yield line
1031 if self._contains_html_diff:
1032 yield '</pre>'
1033 html_escape_val = (self.environment.html_in_footer and
1034 self._contains_html_diff)
1035 footer = self.generate_email_footer(html_escape_val)
1036 if not self.environment.html_in_footer:
1037 footer = self._wrap_for_html(footer)
1038 for line in footer:
1039 yield line
1041 def get_specific_fromaddr(self):
1042 """For kinds of Changes which specify it, return the kind-specific
1043 From address to use."""
1044 return None
1047 class Revision(Change):
1048 """A Change consisting of a single git commit."""
1050 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1052 def __init__(self, reference_change, rev, num, tot):
1053 Change.__init__(self, reference_change.environment)
1054 self.reference_change = reference_change
1055 self.rev = rev
1056 self.change_type = self.reference_change.change_type
1057 self.refname = self.reference_change.refname
1058 self.num = num
1059 self.tot = tot
1060 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1061 self.recipients = self.environment.get_revision_recipients(self)
1063 self.cc_recipients = ''
1064 if self.environment.get_scancommitforcc():
1065 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1066 if self.cc_recipients:
1067 self.environment.log_msg(
1068 'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1))
1070 def _cc_recipients(self):
1071 cc_recipients = []
1072 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1073 lines = message.strip().split('\n')
1074 for line in lines:
1075 m = re.match(self.CC_RE, line)
1076 if m:
1077 cc_recipients.append(m.group('to'))
1079 return cc_recipients
1081 def _compute_values(self):
1082 values = Change._compute_values(self)
1084 oneline = read_git_output(
1085 ['log', '--format=%s', '--no-walk', self.rev.sha1]
1088 max_subject_length = self.environment.get_max_subject_length()
1089 if max_subject_length > 0 and len(oneline) > max_subject_length:
1090 oneline = oneline[:max_subject_length - 6] + ' [...]'
1092 values['rev'] = self.rev.sha1
1093 values['rev_short'] = self.rev.short
1094 values['change_type'] = self.change_type
1095 values['refname'] = self.refname
1096 values['newrev'] = self.rev.sha1
1097 values['short_refname'] = self.reference_change.short_refname
1098 values['refname_type'] = self.reference_change.refname_type
1099 values['reply_to_msgid'] = self.reference_change.msgid
1100 values['num'] = self.num
1101 values['tot'] = self.tot
1102 values['recipients'] = self.recipients
1103 if self.cc_recipients:
1104 values['cc_recipients'] = self.cc_recipients
1105 values['oneline'] = oneline
1106 values['author'] = self.author
1108 reply_to = self.environment.get_reply_to_commit(self)
1109 if reply_to:
1110 values['reply_to'] = reply_to
1112 return values
1114 def generate_email_header(self, **extra_values):
1115 for line in self.expand_header_lines(
1116 REVISION_HEADER_TEMPLATE, **extra_values
1118 yield line
1120 def generate_browse_link(self, base_url):
1121 if '%(' not in base_url:
1122 base_url += '%(id)s'
1123 url = "".join(self.expand_lines(base_url))
1124 if self._content_type == 'html':
1125 for line in self.expand_lines(LINK_HTML_TEMPLATE,
1126 html_escape_val=True,
1127 browse_url=url):
1128 yield line
1129 elif self._content_type == 'plain':
1130 for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1131 html_escape_val=False,
1132 browse_url=url):
1133 yield line
1134 else:
1135 raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1137 def generate_email_intro(self, html_escape_val=False):
1138 for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1139 html_escape_val=html_escape_val):
1140 yield line
1142 def generate_email_body(self, push):
1143 """Show this revision."""
1145 for line in read_git_lines(
1146 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1147 keepends=True,
1148 errors='replace'):
1149 if line.startswith('Date: ') and self.environment.date_substitute:
1150 yield self.environment.date_substitute + line[len('Date: '):]
1151 else:
1152 yield line
1154 def generate_email_footer(self, html_escape_val):
1155 return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1156 html_escape_val=html_escape_val)
1158 def generate_email(self, push, body_filter=None, extra_header_values={}):
1159 self._contains_diff()
1160 return Change.generate_email(self, push, body_filter, extra_header_values)
1162 def get_specific_fromaddr(self):
1163 return self.environment.from_commit
1166 class ReferenceChange(Change):
1167 """A Change to a Git reference.
1169 An abstract class representing a create, update, or delete of a
1170 Git reference. Derived classes handle specific types of reference
1171 (e.g., tags vs. branches). These classes generate the main
1172 reference change email summarizing the reference change and
1173 whether it caused any any commits to be added or removed.
1175 ReferenceChange objects are usually created using the static
1176 create() method, which has the logic to decide which derived class
1177 to instantiate."""
1179 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1181 @staticmethod
1182 def create(environment, oldrev, newrev, refname):
1183 """Return a ReferenceChange object representing the change.
1185 Return an object that represents the type of change that is being
1186 made. oldrev and newrev should be SHA1s or ZEROS."""
1188 old = GitObject(oldrev)
1189 new = GitObject(newrev)
1190 rev = new or old
1192 # The revision type tells us what type the commit is, combined with
1193 # the location of the ref we can decide between
1194 # - working branch
1195 # - tracking branch
1196 # - unannotated tag
1197 # - annotated tag
1198 m = ReferenceChange.REF_RE.match(refname)
1199 if m:
1200 area = m.group('area')
1201 short_refname = m.group('shortname')
1202 else:
1203 area = ''
1204 short_refname = refname
1206 if rev.type == 'tag':
1207 # Annotated tag:
1208 klass = AnnotatedTagChange
1209 elif rev.type == 'commit':
1210 if area == 'tags':
1211 # Non-annotated tag:
1212 klass = NonAnnotatedTagChange
1213 elif area == 'heads':
1214 # Branch:
1215 klass = BranchChange
1216 elif area == 'remotes':
1217 # Tracking branch:
1218 environment.log_warning(
1219 '*** Push-update of tracking branch %r\n'
1220 '*** - incomplete email generated.'
1221 % (refname,)
1223 klass = OtherReferenceChange
1224 else:
1225 # Some other reference namespace:
1226 environment.log_warning(
1227 '*** Push-update of strange reference %r\n'
1228 '*** - incomplete email generated.'
1229 % (refname,)
1231 klass = OtherReferenceChange
1232 else:
1233 # Anything else (is there anything else?)
1234 environment.log_warning(
1235 '*** Unknown type of update to %r (%s)\n'
1236 '*** - incomplete email generated.'
1237 % (refname, rev.type,)
1239 klass = OtherReferenceChange
1241 return klass(
1242 environment,
1243 refname=refname, short_refname=short_refname,
1244 old=old, new=new, rev=rev,
1247 def __init__(self, environment, refname, short_refname, old, new, rev):
1248 Change.__init__(self, environment)
1249 self.change_type = {
1250 (False, True): 'create',
1251 (True, True): 'update',
1252 (True, False): 'delete',
1253 }[bool(old), bool(new)]
1254 self.refname = refname
1255 self.short_refname = short_refname
1256 self.old = old
1257 self.new = new
1258 self.rev = rev
1259 self.msgid = make_msgid()
1260 self.diffopts = environment.diffopts
1261 self.graphopts = environment.graphopts
1262 self.logopts = environment.logopts
1263 self.commitlogopts = environment.commitlogopts
1264 self.showgraph = environment.refchange_showgraph
1265 self.showlog = environment.refchange_showlog
1267 self.header_template = REFCHANGE_HEADER_TEMPLATE
1268 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1269 self.footer_template = FOOTER_TEMPLATE
1271 def _compute_values(self):
1272 values = Change._compute_values(self)
1274 values['change_type'] = self.change_type
1275 values['refname_type'] = self.refname_type
1276 values['refname'] = self.refname
1277 values['short_refname'] = self.short_refname
1278 values['msgid'] = self.msgid
1279 values['recipients'] = self.recipients
1280 values['oldrev'] = str(self.old)
1281 values['oldrev_short'] = self.old.short
1282 values['newrev'] = str(self.new)
1283 values['newrev_short'] = self.new.short
1285 if self.old:
1286 values['oldrev_type'] = self.old.type
1287 if self.new:
1288 values['newrev_type'] = self.new.type
1290 reply_to = self.environment.get_reply_to_refchange(self)
1291 if reply_to:
1292 values['reply_to'] = reply_to
1294 return values
1296 def send_single_combined_email(self, known_added_sha1s):
1297 """Determine if a combined refchange/revision email should be sent
1299 If there is only a single new (non-merge) commit added by a
1300 change, it is useful to combine the ReferenceChange and
1301 Revision emails into one. In such a case, return the single
1302 revision; otherwise, return None.
1304 This method is overridden in BranchChange."""
1306 return None
1308 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1309 """Generate an email describing this change AND specified revision.
1311 Iterate over the lines (including the header lines) of an
1312 email describing this change. If body_filter is not None,
1313 then use it to filter the lines that are intended for the
1314 email body.
1316 The extra_header_values field is received as a dict and not as
1317 **kwargs, to allow passing other keyword arguments in the
1318 future (e.g. passing extra values to generate_email_intro()
1320 This method is overridden in BranchChange."""
1322 raise NotImplementedError
1324 def get_subject(self):
1325 template = {
1326 'create': REF_CREATED_SUBJECT_TEMPLATE,
1327 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1328 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1329 }[self.change_type]
1330 return self.expand(template)
1332 def generate_email_header(self, **extra_values):
1333 if 'subject' not in extra_values:
1334 extra_values['subject'] = self.get_subject()
1336 for line in self.expand_header_lines(
1337 self.header_template, **extra_values
1339 yield line
1341 def generate_email_intro(self, html_escape_val=False):
1342 for line in self.expand_lines(self.intro_template,
1343 html_escape_val=html_escape_val):
1344 yield line
1346 def generate_email_body(self, push):
1347 """Call the appropriate body-generation routine.
1349 Call one of generate_create_summary() /
1350 generate_update_summary() / generate_delete_summary()."""
1352 change_summary = {
1353 'create': self.generate_create_summary,
1354 'delete': self.generate_delete_summary,
1355 'update': self.generate_update_summary,
1356 }[self.change_type](push)
1357 for line in change_summary:
1358 yield line
1360 for line in self.generate_revision_change_summary(push):
1361 yield line
1363 def generate_email_footer(self, html_escape_val):
1364 return self.expand_lines(self.footer_template,
1365 html_escape_val=html_escape_val)
1367 def generate_revision_change_graph(self, push):
1368 if self.showgraph:
1369 args = ['--graph'] + self.graphopts
1370 for newold in ('new', 'old'):
1371 has_newold = False
1372 spec = push.get_commits_spec(newold, self)
1373 for line in git_log(spec, args=args, keepends=True):
1374 if not has_newold:
1375 has_newold = True
1376 yield '\n'
1377 yield 'Graph of %s commits:\n\n' % (
1378 {'new': 'new', 'old': 'discarded'}[newold],)
1379 yield ' ' + line
1380 if has_newold:
1381 yield '\n'
1383 def generate_revision_change_log(self, new_commits_list):
1384 if self.showlog:
1385 yield '\n'
1386 yield 'Detailed log of new commits:\n\n'
1387 for line in read_git_lines(
1388 ['log', '--no-walk'] +
1389 self.logopts +
1390 new_commits_list +
1391 ['--'],
1392 keepends=True,
1394 yield line
1396 def generate_new_revision_summary(self, tot, new_commits_list, push):
1397 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1398 yield line
1399 for line in self.generate_revision_change_graph(push):
1400 yield line
1401 for line in self.generate_revision_change_log(new_commits_list):
1402 yield line
1404 def generate_revision_change_summary(self, push):
1405 """Generate a summary of the revisions added/removed by this change."""
1407 if self.new.commit_sha1 and not self.old.commit_sha1:
1408 # A new reference was created. List the new revisions
1409 # brought by the new reference (i.e., those revisions that
1410 # were not in the repository before this reference
1411 # change).
1412 sha1s = list(push.get_new_commits(self))
1413 sha1s.reverse()
1414 tot = len(sha1s)
1415 new_revisions = [
1416 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1417 for (i, sha1) in enumerate(sha1s)
1420 if new_revisions:
1421 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1422 yield '\n'
1423 for r in new_revisions:
1424 (sha1, subject) = r.rev.get_summary()
1425 yield r.expand(
1426 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1428 yield '\n'
1429 for line in self.generate_new_revision_summary(
1430 tot, [r.rev.sha1 for r in new_revisions], push):
1431 yield line
1432 else:
1433 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1434 yield line
1436 elif self.new.commit_sha1 and self.old.commit_sha1:
1437 # A reference was changed to point at a different commit.
1438 # List the revisions that were removed and/or added *from
1439 # that reference* by this reference change, along with a
1440 # diff between the trees for its old and new values.
1442 # List of the revisions that were added to the branch by
1443 # this update. Note this list can include revisions that
1444 # have already had notification emails; we want such
1445 # revisions in the summary even though we will not send
1446 # new notification emails for them.
1447 adds = list(generate_summaries(
1448 '--topo-order', '--reverse', '%s..%s'
1449 % (self.old.commit_sha1, self.new.commit_sha1,)
1452 # List of the revisions that were removed from the branch
1453 # by this update. This will be empty except for
1454 # non-fast-forward updates.
1455 discards = list(generate_summaries(
1456 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1459 if adds:
1460 new_commits_list = push.get_new_commits(self)
1461 else:
1462 new_commits_list = []
1463 new_commits = CommitSet(new_commits_list)
1465 if discards:
1466 discarded_commits = CommitSet(push.get_discarded_commits(self))
1467 else:
1468 discarded_commits = CommitSet([])
1470 if discards and adds:
1471 for (sha1, subject) in discards:
1472 if sha1 in discarded_commits:
1473 action = 'discard'
1474 else:
1475 action = 'omit'
1476 yield self.expand(
1477 BRIEF_SUMMARY_TEMPLATE, action=action,
1478 rev_short=sha1, text=subject,
1480 for (sha1, subject) in adds:
1481 if sha1 in new_commits:
1482 action = 'new'
1483 else:
1484 action = 'add'
1485 yield self.expand(
1486 BRIEF_SUMMARY_TEMPLATE, action=action,
1487 rev_short=sha1, text=subject,
1489 yield '\n'
1490 for line in self.expand_lines(NON_FF_TEMPLATE):
1491 yield line
1493 elif discards:
1494 for (sha1, subject) in discards:
1495 if sha1 in discarded_commits:
1496 action = 'discard'
1497 else:
1498 action = 'omit'
1499 yield self.expand(
1500 BRIEF_SUMMARY_TEMPLATE, action=action,
1501 rev_short=sha1, text=subject,
1503 yield '\n'
1504 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1505 yield line
1507 elif adds:
1508 (sha1, subject) = self.old.get_summary()
1509 yield self.expand(
1510 BRIEF_SUMMARY_TEMPLATE, action='from',
1511 rev_short=sha1, text=subject,
1513 for (sha1, subject) in adds:
1514 if sha1 in new_commits:
1515 action = 'new'
1516 else:
1517 action = 'add'
1518 yield self.expand(
1519 BRIEF_SUMMARY_TEMPLATE, action=action,
1520 rev_short=sha1, text=subject,
1523 yield '\n'
1525 if new_commits:
1526 for line in self.generate_new_revision_summary(
1527 len(new_commits), new_commits_list, push):
1528 yield line
1529 else:
1530 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1531 yield line
1532 for line in self.generate_revision_change_graph(push):
1533 yield line
1535 # The diffstat is shown from the old revision to the new
1536 # revision. This is to show the truth of what happened in
1537 # this change. There's no point showing the stat from the
1538 # base to the new revision because the base is effectively a
1539 # random revision at this point - the user will be interested
1540 # in what this revision changed - including the undoing of
1541 # previous revisions in the case of non-fast-forward updates.
1542 yield '\n'
1543 yield 'Summary of changes:\n'
1544 for line in read_git_lines(
1545 ['diff-tree'] +
1546 self.diffopts +
1547 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1548 keepends=True,
1550 yield line
1552 elif self.old.commit_sha1 and not self.new.commit_sha1:
1553 # A reference was deleted. List the revisions that were
1554 # removed from the repository by this reference change.
1556 sha1s = list(push.get_discarded_commits(self))
1557 tot = len(sha1s)
1558 discarded_revisions = [
1559 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1560 for (i, sha1) in enumerate(sha1s)
1563 if discarded_revisions:
1564 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1565 yield line
1566 yield '\n'
1567 for r in discarded_revisions:
1568 (sha1, subject) = r.rev.get_summary()
1569 yield r.expand(
1570 BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,
1572 for line in self.generate_revision_change_graph(push):
1573 yield line
1574 else:
1575 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1576 yield line
1578 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1579 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1580 yield line
1582 def generate_create_summary(self, push):
1583 """Called for the creation of a reference."""
1585 # This is a new reference and so oldrev is not valid
1586 (sha1, subject) = self.new.get_summary()
1587 yield self.expand(
1588 BRIEF_SUMMARY_TEMPLATE, action='at',
1589 rev_short=sha1, text=subject,
1591 yield '\n'
1593 def generate_update_summary(self, push):
1594 """Called for the change of a pre-existing branch."""
1596 return iter([])
1598 def generate_delete_summary(self, push):
1599 """Called for the deletion of any type of reference."""
1601 (sha1, subject) = self.old.get_summary()
1602 yield self.expand(
1603 BRIEF_SUMMARY_TEMPLATE, action='was',
1604 rev_short=sha1, text=subject,
1606 yield '\n'
1608 def get_specific_fromaddr(self):
1609 return self.environment.from_refchange
1612 class BranchChange(ReferenceChange):
1613 refname_type = 'branch'
1615 def __init__(self, environment, refname, short_refname, old, new, rev):
1616 ReferenceChange.__init__(
1617 self, environment,
1618 refname=refname, short_refname=short_refname,
1619 old=old, new=new, rev=rev,
1621 self.recipients = environment.get_refchange_recipients(self)
1622 self._single_revision = None
1624 def send_single_combined_email(self, known_added_sha1s):
1625 if not self.environment.combine_when_single_commit:
1626 return None
1628 # In the sadly-all-too-frequent usecase of people pushing only
1629 # one of their commits at a time to a repository, users feel
1630 # the reference change summary emails are noise rather than
1631 # important signal. This is because, in this particular
1632 # usecase, there is a reference change summary email for each
1633 # new commit, and all these summaries do is point out that
1634 # there is one new commit (which can readily be inferred by
1635 # the existence of the individual revision email that is also
1636 # sent). In such cases, our users prefer there to be a combined
1637 # reference change summary/new revision email.
1639 # So, if the change is an update and it doesn't discard any
1640 # commits, and it adds exactly one non-merge commit (gerrit
1641 # forces a workflow where every commit is individually merged
1642 # and the git-multimail hook fired off for just this one
1643 # change), then we send a combined refchange/revision email.
1644 try:
1645 # If this change is a reference update that doesn't discard
1646 # any commits...
1647 if self.change_type != 'update':
1648 return None
1650 if read_git_lines(
1651 ['merge-base', self.old.sha1, self.new.sha1]
1652 ) != [self.old.sha1]:
1653 return None
1655 # Check if this update introduced exactly one non-merge
1656 # commit:
1658 def split_line(line):
1659 """Split line into (sha1, [parent,...])."""
1661 words = line.split()
1662 return (words[0], words[1:])
1664 # Get the new commits introduced by the push as a list of
1665 # (sha1, [parent,...])
1666 new_commits = [
1667 split_line(line)
1668 for line in read_git_lines(
1670 'log', '-3', '--format=%H %P',
1671 '%s..%s' % (self.old.sha1, self.new.sha1),
1676 if not new_commits:
1677 return None
1679 # If the newest commit is a merge, save it for a later check
1680 # but otherwise ignore it
1681 merge = None
1682 tot = len(new_commits)
1683 if len(new_commits[0][1]) > 1:
1684 merge = new_commits[0][0]
1685 del new_commits[0]
1687 # Our primary check: we can't combine if more than one commit
1688 # is introduced. We also currently only combine if the new
1689 # commit is a non-merge commit, though it may make sense to
1690 # combine if it is a merge as well.
1691 if not (
1692 len(new_commits) == 1 and
1693 len(new_commits[0][1]) == 1 and
1694 new_commits[0][0] in known_added_sha1s
1696 return None
1698 # We do not want to combine revision and refchange emails if
1699 # those go to separate locations.
1700 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1701 if rev.recipients != self.recipients:
1702 return None
1704 # We ignored the newest commit if it was just a merge of the one
1705 # commit being introduced. But we don't want to ignore that
1706 # merge commit it it involved conflict resolutions. Check that.
1707 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1708 return None
1710 # We can combine the refchange and one new revision emails
1711 # into one. Return the Revision that a combined email should
1712 # be sent about.
1713 return rev
1714 except CommandError:
1715 # Cannot determine number of commits in old..new or new..old;
1716 # don't combine reference/revision emails:
1717 return None
1719 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1720 values = revision.get_values()
1721 if extra_header_values:
1722 values.update(extra_header_values)
1723 if 'subject' not in extra_header_values:
1724 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1726 self._single_revision = revision
1727 self._contains_diff()
1728 self.header_template = COMBINED_HEADER_TEMPLATE
1729 self.intro_template = COMBINED_INTRO_TEMPLATE
1730 self.footer_template = COMBINED_FOOTER_TEMPLATE
1732 def revision_gen_link(base_url):
1733 # revision is used only to generate the body, and
1734 # _content_type is set while generating headers. Get it
1735 # from the BranchChange object.
1736 revision._content_type = self._content_type
1737 return revision.generate_browse_link(base_url)
1738 self.generate_browse_link = revision_gen_link
1739 for line in self.generate_email(push, body_filter, values):
1740 yield line
1742 def generate_email_body(self, push):
1743 '''Call the appropriate body generation routine.
1745 If this is a combined refchange/revision email, the special logic
1746 for handling this combined email comes from this function. For
1747 other cases, we just use the normal handling.'''
1749 # If self._single_revision isn't set; don't override
1750 if not self._single_revision:
1751 for line in super(BranchChange, self).generate_email_body(push):
1752 yield line
1753 return
1755 # This is a combined refchange/revision email; we first provide
1756 # some info from the refchange portion, and then call the revision
1757 # generate_email_body function to handle the revision portion.
1758 adds = list(generate_summaries(
1759 '--topo-order', '--reverse', '%s..%s'
1760 % (self.old.commit_sha1, self.new.commit_sha1,)
1763 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1764 for (sha1, subject) in adds:
1765 yield self.expand(
1766 BRIEF_SUMMARY_TEMPLATE, action='new',
1767 rev_short=sha1, text=subject,
1770 yield self._single_revision.rev.short + " is described below\n"
1771 yield '\n'
1773 for line in self._single_revision.generate_email_body(push):
1774 yield line
1777 class AnnotatedTagChange(ReferenceChange):
1778 refname_type = 'annotated tag'
1780 def __init__(self, environment, refname, short_refname, old, new, rev):
1781 ReferenceChange.__init__(
1782 self, environment,
1783 refname=refname, short_refname=short_refname,
1784 old=old, new=new, rev=rev,
1786 self.recipients = environment.get_announce_recipients(self)
1787 self.show_shortlog = environment.announce_show_shortlog
1789 ANNOTATED_TAG_FORMAT = (
1790 '%(*objectname)\n'
1791 '%(*objecttype)\n'
1792 '%(taggername)\n'
1793 '%(taggerdate)'
1796 def describe_tag(self, push):
1797 """Describe the new value of an annotated tag."""
1799 # Use git for-each-ref to pull out the individual fields from
1800 # the tag
1801 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1802 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1805 yield self.expand(
1806 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1807 rev_short=tagobject, text='(%s)' % (tagtype,),
1809 if tagtype == 'commit':
1810 # If the tagged object is a commit, then we assume this is a
1811 # release, and so we calculate which tag this tag is
1812 # replacing
1813 try:
1814 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1815 except CommandError:
1816 prevtag = None
1817 if prevtag:
1818 yield ' replaces %s\n' % (prevtag,)
1819 else:
1820 prevtag = None
1821 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1823 yield ' by %s\n' % (tagger,)
1824 yield ' on %s\n' % (tagged,)
1825 yield '\n'
1827 # Show the content of the tag message; this might contain a
1828 # change log or release notes so is worth displaying.
1829 yield LOGBEGIN
1830 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1831 contents = contents[contents.index('\n') + 1:]
1832 if contents and contents[-1][-1:] != '\n':
1833 contents.append('\n')
1834 for line in contents:
1835 yield line
1837 if self.show_shortlog and tagtype == 'commit':
1838 # Only commit tags make sense to have rev-list operations
1839 # performed on them
1840 yield '\n'
1841 if prevtag:
1842 # Show changes since the previous release
1843 revlist = read_git_output(
1844 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1845 keepends=True,
1847 else:
1848 # No previous tag, show all the changes since time
1849 # began
1850 revlist = read_git_output(
1851 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1852 keepends=True,
1854 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1855 yield line
1857 yield LOGEND
1858 yield '\n'
1860 def generate_create_summary(self, push):
1861 """Called for the creation of an annotated tag."""
1863 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1864 yield line
1866 for line in self.describe_tag(push):
1867 yield line
1869 def generate_update_summary(self, push):
1870 """Called for the update of an annotated tag.
1872 This is probably a rare event and may not even be allowed."""
1874 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1875 yield line
1877 for line in self.describe_tag(push):
1878 yield line
1880 def generate_delete_summary(self, push):
1881 """Called when a non-annotated reference is updated."""
1883 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1884 yield line
1886 yield self.expand(' tag was %(oldrev_short)s\n')
1887 yield '\n'
1890 class NonAnnotatedTagChange(ReferenceChange):
1891 refname_type = 'tag'
1893 def __init__(self, environment, refname, short_refname, old, new, rev):
1894 ReferenceChange.__init__(
1895 self, environment,
1896 refname=refname, short_refname=short_refname,
1897 old=old, new=new, rev=rev,
1899 self.recipients = environment.get_refchange_recipients(self)
1901 def generate_create_summary(self, push):
1902 """Called for the creation of an annotated tag."""
1904 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1905 yield line
1907 def generate_update_summary(self, push):
1908 """Called when a non-annotated reference is updated."""
1910 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1911 yield line
1913 def generate_delete_summary(self, push):
1914 """Called when a non-annotated reference is updated."""
1916 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1917 yield line
1919 for line in ReferenceChange.generate_delete_summary(self, push):
1920 yield line
1923 class OtherReferenceChange(ReferenceChange):
1924 refname_type = 'reference'
1926 def __init__(self, environment, refname, short_refname, old, new, rev):
1927 # We use the full refname as short_refname, because otherwise
1928 # the full name of the reference would not be obvious from the
1929 # text of the email.
1930 ReferenceChange.__init__(
1931 self, environment,
1932 refname=refname, short_refname=refname,
1933 old=old, new=new, rev=rev,
1935 self.recipients = environment.get_refchange_recipients(self)
1938 class Mailer(object):
1939 """An object that can send emails."""
1941 def __init__(self, environment):
1942 self.environment = environment
1944 def send(self, lines, to_addrs):
1945 """Send an email consisting of lines.
1947 lines must be an iterable over the lines constituting the
1948 header and body of the email. to_addrs is a list of recipient
1949 addresses (can be needed even if lines already contains a
1950 "To:" field). It can be either a string (comma-separated list
1951 of email addresses) or a Python list of individual email
1952 addresses.
1956 raise NotImplementedError()
1959 class SendMailer(Mailer):
1960 """Send emails using 'sendmail -oi -t'."""
1962 SENDMAIL_CANDIDATES = [
1963 '/usr/sbin/sendmail',
1964 '/usr/lib/sendmail',
1967 @staticmethod
1968 def find_sendmail():
1969 for path in SendMailer.SENDMAIL_CANDIDATES:
1970 if os.access(path, os.X_OK):
1971 return path
1972 else:
1973 raise ConfigurationException(
1974 'No sendmail executable found. '
1975 'Try setting multimailhook.sendmailCommand.'
1978 def __init__(self, environment, command=None, envelopesender=None):
1979 """Construct a SendMailer instance.
1981 command should be the command and arguments used to invoke
1982 sendmail, as a list of strings. If an envelopesender is
1983 provided, it will also be passed to the command, via '-f
1984 envelopesender'."""
1985 super(SendMailer, self).__init__(environment)
1986 if command:
1987 self.command = command[:]
1988 else:
1989 self.command = [self.find_sendmail(), '-oi', '-t']
1991 if envelopesender:
1992 self.command.extend(['-f', envelopesender])
1994 def send(self, lines, to_addrs):
1995 try:
1996 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1997 except OSError:
1998 self.environment.get_logger().error(
1999 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
2000 '*** %s\n' % sys.exc_info()[1] +
2001 '*** Try setting multimailhook.mailer to "smtp"\n' +
2002 '*** to send emails without using the sendmail command.\n'
2004 sys.exit(1)
2005 try:
2006 lines = (str_to_bytes(line) for line in lines)
2007 p.stdin.writelines(lines)
2008 except Exception:
2009 self.environment.get_logger().error(
2010 '*** Error while generating commit email\n'
2011 '*** - mail sending aborted.\n'
2013 if hasattr(p, 'terminate'):
2014 # subprocess.terminate() is not available in Python 2.4
2015 p.terminate()
2016 else:
2017 import signal
2018 os.kill(p.pid, signal.SIGTERM)
2019 raise
2020 else:
2021 p.stdin.close()
2022 retcode = p.wait()
2023 if retcode:
2024 raise CommandError(self.command, retcode)
2027 class SMTPMailer(Mailer):
2028 """Send emails using Python's smtplib."""
2030 def __init__(self, environment,
2031 envelopesender, smtpserver,
2032 smtpservertimeout=10.0, smtpserverdebuglevel=0,
2033 smtpencryption='none',
2034 smtpuser='', smtppass='',
2035 smtpcacerts=''
2037 super(SMTPMailer, self).__init__(environment)
2038 if not envelopesender:
2039 self.environment.get_logger().error(
2040 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2041 'please set either multimailhook.envelopeSender or user.email\n'
2043 sys.exit(1)
2044 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2045 raise ConfigurationException(
2046 'Cannot use SMTPMailer with security option ssl '
2047 'without options username and password.'
2049 self.envelopesender = envelopesender
2050 self.smtpserver = smtpserver
2051 self.smtpservertimeout = smtpservertimeout
2052 self.smtpserverdebuglevel = smtpserverdebuglevel
2053 self.security = smtpencryption
2054 self.username = smtpuser
2055 self.password = smtppass
2056 self.smtpcacerts = smtpcacerts
2057 try:
2058 def call(klass, server, timeout):
2059 try:
2060 return klass(server, timeout=timeout)
2061 except TypeError:
2062 # Old Python versions do not have timeout= argument.
2063 return klass(server)
2064 if self.security == 'none':
2065 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2066 elif self.security == 'ssl':
2067 if self.smtpcacerts:
2068 raise smtplib.SMTPException(
2069 "Checking certificate is not supported for ssl, prefer starttls"
2071 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2072 elif self.security == 'tls':
2073 if 'ssl' not in sys.modules:
2074 self.environment.get_logger().error(
2075 '*** Your Python version does not have the ssl library installed\n'
2076 '*** smtpEncryption=tls is not available.\n'
2077 '*** Either upgrade Python to 2.6 or later\n'
2078 ' or use git_multimail.py version 1.2.\n')
2079 if ':' not in self.smtpserver:
2080 self.smtpserver += ':587' # default port for TLS
2081 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2082 # start: ehlo + starttls
2083 # equivalent to
2084 # self.smtp.ehlo()
2085 # self.smtp.starttls()
2086 # with acces to the ssl layer
2087 self.smtp.ehlo()
2088 if not self.smtp.has_extn("starttls"):
2089 raise smtplib.SMTPException("STARTTLS extension not supported by server")
2090 resp, reply = self.smtp.docmd("STARTTLS")
2091 if resp != 220:
2092 raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2093 if self.smtpcacerts:
2094 self.smtp.sock = ssl.wrap_socket(
2095 self.smtp.sock,
2096 ca_certs=self.smtpcacerts,
2097 cert_reqs=ssl.CERT_REQUIRED
2099 else:
2100 self.smtp.sock = ssl.wrap_socket(
2101 self.smtp.sock,
2102 cert_reqs=ssl.CERT_NONE
2104 self.environment.get_logger().error(
2105 '*** Warning, the server certificat is not verified (smtp) ***\n'
2106 '*** set the option smtpCACerts ***\n'
2108 if not hasattr(self.smtp.sock, "read"):
2109 # using httplib.FakeSocket with Python 2.5.x or earlier
2110 self.smtp.sock.read = self.smtp.sock.recv
2111 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2112 self.smtp.helo_resp = None
2113 self.smtp.ehlo_resp = None
2114 self.smtp.esmtp_features = {}
2115 self.smtp.does_esmtp = 0
2116 # end: ehlo + starttls
2117 self.smtp.ehlo()
2118 else:
2119 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2120 sys.exit(1)
2121 if self.smtpserverdebuglevel > 0:
2122 sys.stdout.write(
2123 "*** Setting debug on for SMTP server connection (%s) ***\n"
2124 % self.smtpserverdebuglevel)
2125 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2126 except Exception:
2127 self.environment.get_logger().error(
2128 '*** Error establishing SMTP connection to %s ***\n'
2129 '*** %s\n'
2130 % (self.smtpserver, sys.exc_info()[1]))
2131 sys.exit(1)
2133 def __del__(self):
2134 if hasattr(self, 'smtp'):
2135 self.smtp.quit()
2136 del self.smtp
2138 def send(self, lines, to_addrs):
2139 try:
2140 if self.username or self.password:
2141 self.smtp.login(self.username, self.password)
2142 msg = ''.join(lines)
2143 # turn comma-separated list into Python list if needed.
2144 if is_string(to_addrs):
2145 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2146 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2147 except smtplib.SMTPResponseException:
2148 err = sys.exc_info()[1]
2149 self.environment.get_logger().error(
2150 '*** Error sending email ***\n'
2151 '*** Error %d: %s\n'
2152 % (err.smtp_code, bytes_to_str(err.smtp_error)))
2153 try:
2154 smtp = self.smtp
2155 # delete the field before quit() so that in case of
2156 # error, self.smtp is deleted anyway.
2157 del self.smtp
2158 smtp.quit()
2159 except:
2160 self.environment.get_logger().error(
2161 '*** Error closing the SMTP connection ***\n'
2162 '*** Exiting anyway ... ***\n'
2163 '*** %s\n' % sys.exc_info()[1])
2164 sys.exit(1)
2167 class OutputMailer(Mailer):
2168 """Write emails to an output stream, bracketed by lines of '=' characters.
2170 This is intended for debugging purposes."""
2172 SEPARATOR = '=' * 75 + '\n'
2174 def __init__(self, f):
2175 self.f = f
2177 def send(self, lines, to_addrs):
2178 write_str(self.f, self.SEPARATOR)
2179 for line in lines:
2180 write_str(self.f, line)
2181 write_str(self.f, self.SEPARATOR)
2184 def get_git_dir():
2185 """Determine GIT_DIR.
2187 Determine GIT_DIR either from the GIT_DIR environment variable or
2188 from the working directory, using Git's usual rules."""
2190 try:
2191 return read_git_output(['rev-parse', '--git-dir'])
2192 except CommandError:
2193 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2194 sys.exit(1)
2197 class Environment(object):
2198 """Describes the environment in which the push is occurring.
2200 An Environment object encapsulates information about the local
2201 environment. For example, it knows how to determine:
2203 * the name of the repository to which the push occurred
2205 * what user did the push
2207 * what users want to be informed about various types of changes.
2209 An Environment object is expected to have the following methods:
2211 get_repo_shortname()
2213 Return a short name for the repository, for display
2214 purposes.
2216 get_repo_path()
2218 Return the absolute path to the Git repository.
2220 get_emailprefix()
2222 Return a string that will be prefixed to every email's
2223 subject.
2225 get_pusher()
2227 Return the username of the person who pushed the changes.
2228 This value is used in the email body to indicate who
2229 pushed the change.
2231 get_pusher_email() (may return None)
2233 Return the email address of the person who pushed the
2234 changes. The value should be a single RFC 2822 email
2235 address as a string; e.g., "Joe User <user@example.com>"
2236 if available, otherwise "user@example.com". If set, the
2237 value is used as the Reply-To address for refchange
2238 emails. If it is impossible to determine the pusher's
2239 email, this attribute should be set to None (in which case
2240 no Reply-To header will be output).
2242 get_sender()
2244 Return the address to be used as the 'From' email address
2245 in the email envelope.
2247 get_fromaddr(change=None)
2249 Return the 'From' email address used in the email 'From:'
2250 headers. If the change is known when this function is
2251 called, it is passed in as the 'change' parameter. (May
2252 be a full RFC 2822 email address like 'Joe User
2253 <user@example.com>'.)
2255 get_administrator()
2257 Return the name and/or email of the repository
2258 administrator. This value is used in the footer as the
2259 person to whom requests to be removed from the
2260 notification list should be sent. Ideally, it should
2261 include a valid email address.
2263 get_reply_to_refchange()
2264 get_reply_to_commit()
2266 Return the address to use in the email "Reply-To" header,
2267 as a string. These can be an RFC 2822 email address, or
2268 None to omit the "Reply-To" header.
2269 get_reply_to_refchange() is used for refchange emails;
2270 get_reply_to_commit() is used for individual commit
2271 emails.
2273 get_ref_filter_regex()
2275 Return a tuple -- a compiled regex, and a boolean indicating
2276 whether the regex picks refs to include (if False, the regex
2277 matches on refs to exclude).
2279 get_default_ref_ignore_regex()
2281 Return a regex that should be ignored for both what emails
2282 to send and when computing what commits are considered new
2283 to the repository. Default is "^refs/notes/".
2285 get_max_subject_length()
2287 Return an int giving the maximal length for the subject
2288 (git log --oneline).
2290 They should also define the following attributes:
2292 announce_show_shortlog (bool)
2294 True iff announce emails should include a shortlog.
2296 commit_email_format (string)
2298 If "html", generate commit emails in HTML instead of plain text
2299 used by default.
2301 html_in_intro (bool)
2302 html_in_footer (bool)
2304 When generating HTML emails, the introduction (respectively,
2305 the footer) will be HTML-escaped iff html_in_intro (respectively,
2306 the footer) is true. When false, only the values used to expand
2307 the template are escaped.
2309 refchange_showgraph (bool)
2311 True iff refchanges emails should include a detailed graph.
2313 refchange_showlog (bool)
2315 True iff refchanges emails should include a detailed log.
2317 diffopts (list of strings)
2319 The options that should be passed to 'git diff' for the
2320 summary email. The value should be a list of strings
2321 representing words to be passed to the command.
2323 graphopts (list of strings)
2325 Analogous to diffopts, but contains options passed to
2326 'git log --graph' when generating the detailed graph for
2327 a set of commits (see refchange_showgraph)
2329 logopts (list of strings)
2331 Analogous to diffopts, but contains options passed to
2332 'git log' when generating the detailed log for a set of
2333 commits (see refchange_showlog)
2335 commitlogopts (list of strings)
2337 The options that should be passed to 'git log' for each
2338 commit mail. The value should be a list of strings
2339 representing words to be passed to the command.
2341 date_substitute (string)
2343 String to be used in substitution for 'Date:' at start of
2344 line in the output of 'git log'.
2346 quiet (bool)
2347 On success do not write to stderr
2349 stdout (bool)
2350 Write email to stdout rather than emailing. Useful for debugging
2352 combine_when_single_commit (bool)
2354 True if a combined email should be produced when a single
2355 new commit is pushed to a branch, False otherwise.
2357 from_refchange, from_commit (strings)
2359 Addresses to use for the From: field for refchange emails
2360 and commit emails respectively. Set from
2361 multimailhook.fromRefchange and multimailhook.fromCommit
2362 by ConfigEnvironmentMixin.
2364 log_file, error_log_file, debug_log_file (string)
2366 Name of a file to which logs should be sent.
2368 verbose (int)
2370 How verbose the system should be.
2371 - 0 (default): show info, errors, ...
2372 - 1 : show basic debug info
2375 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2377 def __init__(self, osenv=None):
2378 self.osenv = osenv or os.environ
2379 self.announce_show_shortlog = False
2380 self.commit_email_format = "text"
2381 self.html_in_intro = False
2382 self.html_in_footer = False
2383 self.commitBrowseURL = None
2384 self.maxcommitemails = 500
2385 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2386 self.graphopts = ['--oneline', '--decorate']
2387 self.logopts = []
2388 self.refchange_showgraph = False
2389 self.refchange_showlog = False
2390 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2391 self.date_substitute = 'AuthorDate: '
2392 self.quiet = False
2393 self.stdout = False
2394 self.combine_when_single_commit = True
2395 self.logger = None
2397 self.COMPUTED_KEYS = [
2398 'administrator',
2399 'charset',
2400 'emailprefix',
2401 'pusher',
2402 'pusher_email',
2403 'repo_path',
2404 'repo_shortname',
2405 'sender',
2408 self._values = None
2410 def get_logger(self):
2411 """Get (possibly creates) the logger associated to this environment."""
2412 if self.logger is None:
2413 self.logger = Logger(self)
2414 return self.logger
2416 def get_repo_shortname(self):
2417 """Use the last part of the repo path, with ".git" stripped off if present."""
2419 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2420 m = self.REPO_NAME_RE.match(basename)
2421 if m:
2422 return m.group('name')
2423 else:
2424 return basename
2426 def get_pusher(self):
2427 raise NotImplementedError()
2429 def get_pusher_email(self):
2430 return None
2432 def get_fromaddr(self, change=None):
2433 config = Config('user')
2434 fromname = config.get('name', default='')
2435 fromemail = config.get('email', default='')
2436 if fromemail:
2437 return formataddr([fromname, fromemail])
2438 return self.get_sender()
2440 def get_administrator(self):
2441 return 'the administrator of this repository'
2443 def get_emailprefix(self):
2444 return ''
2446 def get_repo_path(self):
2447 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2448 path = get_git_dir()
2449 else:
2450 path = read_git_output(['rev-parse', '--show-toplevel'])
2451 return os.path.abspath(path)
2453 def get_charset(self):
2454 return CHARSET
2456 def get_values(self):
2457 """Return a dictionary {keyword: expansion} for this Environment.
2459 This method is called by Change._compute_values(). The keys
2460 in the returned dictionary are available to be used in any of
2461 the templates. The dictionary is created by calling
2462 self.get_NAME() for each of the attributes named in
2463 COMPUTED_KEYS and recording those that do not return None.
2464 The return value is always a new dictionary."""
2466 if self._values is None:
2467 values = {'': ''} # %()s expands to the empty string.
2469 for key in self.COMPUTED_KEYS:
2470 value = getattr(self, 'get_%s' % (key,))()
2471 if value is not None:
2472 values[key] = value
2474 self._values = values
2476 return self._values.copy()
2478 def get_refchange_recipients(self, refchange):
2479 """Return the recipients for notifications about refchange.
2481 Return the list of email addresses to which notifications
2482 about the specified ReferenceChange should be sent."""
2484 raise NotImplementedError()
2486 def get_announce_recipients(self, annotated_tag_change):
2487 """Return the recipients for notifications about annotated_tag_change.
2489 Return the list of email addresses to which notifications
2490 about the specified AnnotatedTagChange should be sent."""
2492 raise NotImplementedError()
2494 def get_reply_to_refchange(self, refchange):
2495 return self.get_pusher_email()
2497 def get_revision_recipients(self, revision):
2498 """Return the recipients for messages about revision.
2500 Return the list of email addresses to which notifications
2501 about the specified Revision should be sent. This method
2502 could be overridden, for example, to take into account the
2503 contents of the revision when deciding whom to notify about
2504 it. For example, there could be a scheme for users to express
2505 interest in particular files or subdirectories, and only
2506 receive notification emails for revisions that affecting those
2507 files."""
2509 raise NotImplementedError()
2511 def get_reply_to_commit(self, revision):
2512 return revision.author
2514 def get_default_ref_ignore_regex(self):
2515 # The commit messages of git notes are essentially meaningless
2516 # and "filenames" in git notes commits are an implementational
2517 # detail that might surprise users at first. As such, we
2518 # would need a completely different method for handling emails
2519 # of git notes in order for them to be of benefit for users,
2520 # which we simply do not have right now.
2521 return "^refs/notes/"
2523 def get_max_subject_length(self):
2524 """Return the maximal subject line (git log --oneline) length.
2525 Longer subject lines will be truncated."""
2526 raise NotImplementedError()
2528 def filter_body(self, lines):
2529 """Filter the lines intended for an email body.
2531 lines is an iterable over the lines that would go into the
2532 email body. Filter it (e.g., limit the number of lines, the
2533 line length, character set, etc.), returning another iterable.
2534 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2535 for classes implementing this functionality."""
2537 return lines
2539 def log_msg(self, msg):
2540 """Write the string msg on a log file or on stderr.
2542 Sends the text to stderr by default, override to change the behavior."""
2543 self.get_logger().info(msg)
2545 def log_warning(self, msg):
2546 """Write the string msg on a log file or on stderr.
2548 Sends the text to stderr by default, override to change the behavior."""
2549 self.get_logger().warning(msg)
2551 def log_error(self, msg):
2552 """Write the string msg on a log file or on stderr.
2554 Sends the text to stderr by default, override to change the behavior."""
2555 self.get_logger().error(msg)
2557 def check(self):
2558 pass
2561 class ConfigEnvironmentMixin(Environment):
2562 """A mixin that sets self.config to its constructor's config argument.
2564 This class's constructor consumes the "config" argument.
2566 Mixins that need to inspect the config should inherit from this
2567 class (1) to make sure that "config" is still in the constructor
2568 arguments with its own constructor runs and/or (2) to be sure that
2569 self.config is set after construction."""
2571 def __init__(self, config, **kw):
2572 super(ConfigEnvironmentMixin, self).__init__(**kw)
2573 self.config = config
2576 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2577 """An Environment that reads most of its information from "git config"."""
2579 @staticmethod
2580 def forbid_field_values(name, value, forbidden):
2581 for forbidden_val in forbidden:
2582 if value is not None and value.lower() == forbidden:
2583 raise ConfigurationException(
2584 '"%s" is not an allowed setting for %s' % (value, name)
2587 def __init__(self, config, **kw):
2588 super(ConfigOptionsEnvironmentMixin, self).__init__(
2589 config=config, **kw
2592 for var, cfg in (
2593 ('announce_show_shortlog', 'announceshortlog'),
2594 ('refchange_showgraph', 'refchangeShowGraph'),
2595 ('refchange_showlog', 'refchangeshowlog'),
2596 ('quiet', 'quiet'),
2597 ('stdout', 'stdout'),
2599 val = config.get_bool(cfg)
2600 if val is not None:
2601 setattr(self, var, val)
2603 commit_email_format = config.get('commitEmailFormat')
2604 if commit_email_format is not None:
2605 if commit_email_format != "html" and commit_email_format != "text":
2606 self.log_warning(
2607 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2608 commit_email_format +
2609 '*** Expected either "text" or "html". Ignoring.\n'
2611 else:
2612 self.commit_email_format = commit_email_format
2614 html_in_intro = config.get_bool('htmlInIntro')
2615 if html_in_intro is not None:
2616 self.html_in_intro = html_in_intro
2618 html_in_footer = config.get_bool('htmlInFooter')
2619 if html_in_footer is not None:
2620 self.html_in_footer = html_in_footer
2622 self.commitBrowseURL = config.get('commitBrowseURL')
2624 maxcommitemails = config.get('maxcommitemails')
2625 if maxcommitemails is not None:
2626 try:
2627 self.maxcommitemails = int(maxcommitemails)
2628 except ValueError:
2629 self.log_warning(
2630 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2631 % maxcommitemails +
2632 '*** Expected a number. Ignoring.\n'
2635 diffopts = config.get('diffopts')
2636 if diffopts is not None:
2637 self.diffopts = shlex.split(diffopts)
2639 graphopts = config.get('graphOpts')
2640 if graphopts is not None:
2641 self.graphopts = shlex.split(graphopts)
2643 logopts = config.get('logopts')
2644 if logopts is not None:
2645 self.logopts = shlex.split(logopts)
2647 commitlogopts = config.get('commitlogopts')
2648 if commitlogopts is not None:
2649 self.commitlogopts = shlex.split(commitlogopts)
2651 date_substitute = config.get('dateSubstitute')
2652 if date_substitute == 'none':
2653 self.date_substitute = None
2654 elif date_substitute is not None:
2655 self.date_substitute = date_substitute
2657 reply_to = config.get('replyTo')
2658 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2659 self.forbid_field_values('replyToRefchange',
2660 self.__reply_to_refchange,
2661 ['author'])
2662 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2664 self.from_refchange = config.get('fromRefchange')
2665 self.forbid_field_values('fromRefchange',
2666 self.from_refchange,
2667 ['author', 'none'])
2668 self.from_commit = config.get('fromCommit')
2669 self.forbid_field_values('fromCommit',
2670 self.from_commit,
2671 ['none'])
2673 combine = config.get_bool('combineWhenSingleCommit')
2674 if combine is not None:
2675 self.combine_when_single_commit = combine
2677 self.log_file = config.get('logFile', default=None)
2678 self.error_log_file = config.get('errorLogFile', default=None)
2679 self.debug_log_file = config.get('debugLogFile', default=None)
2680 if config.get_bool('Verbose', default=False):
2681 self.verbose = 1
2682 else:
2683 self.verbose = 0
2685 def get_administrator(self):
2686 return (
2687 self.config.get('administrator') or
2688 self.get_sender() or
2689 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2692 def get_repo_shortname(self):
2693 return (
2694 self.config.get('reponame') or
2695 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2698 def get_emailprefix(self):
2699 emailprefix = self.config.get('emailprefix')
2700 if emailprefix is not None:
2701 emailprefix = emailprefix.strip()
2702 if emailprefix:
2703 emailprefix += ' '
2704 else:
2705 emailprefix = '[%(repo_shortname)s] '
2706 short_name = self.get_repo_shortname()
2707 try:
2708 return emailprefix % {'repo_shortname': short_name}
2709 except:
2710 self.get_logger().error(
2711 '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix +
2712 '*** %s\n' % sys.exc_info()[1] +
2713 "*** Only the '%(repo_shortname)s' placeholder is allowed\n"
2715 raise ConfigurationException(
2716 '"%s" is not an allowed setting for emailPrefix' % emailprefix
2719 def get_sender(self):
2720 return self.config.get('envelopesender')
2722 def process_addr(self, addr, change):
2723 if addr.lower() == 'author':
2724 if hasattr(change, 'author'):
2725 return change.author
2726 else:
2727 return None
2728 elif addr.lower() == 'pusher':
2729 return self.get_pusher_email()
2730 elif addr.lower() == 'none':
2731 return None
2732 else:
2733 return addr
2735 def get_fromaddr(self, change=None):
2736 fromaddr = self.config.get('from')
2737 if change:
2738 specific_fromaddr = change.get_specific_fromaddr()
2739 if specific_fromaddr:
2740 fromaddr = specific_fromaddr
2741 if fromaddr:
2742 fromaddr = self.process_addr(fromaddr, change)
2743 if fromaddr:
2744 return fromaddr
2745 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2747 def get_reply_to_refchange(self, refchange):
2748 if self.__reply_to_refchange is None:
2749 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2750 else:
2751 return self.process_addr(self.__reply_to_refchange, refchange)
2753 def get_reply_to_commit(self, revision):
2754 if self.__reply_to_commit is None:
2755 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2756 else:
2757 return self.process_addr(self.__reply_to_commit, revision)
2759 def get_scancommitforcc(self):
2760 return self.config.get('scancommitforcc')
2763 class FilterLinesEnvironmentMixin(Environment):
2764 """Handle encoding and maximum line length of body lines.
2766 email_max_line_length (int or None)
2768 The maximum length of any single line in the email body.
2769 Longer lines are truncated at that length with ' [...]'
2770 appended.
2772 strict_utf8 (bool)
2774 If this field is set to True, then the email body text is
2775 expected to be UTF-8. Any invalid characters are
2776 converted to U+FFFD, the Unicode replacement character
2777 (encoded as UTF-8, of course).
2781 def __init__(self, strict_utf8=True,
2782 email_max_line_length=500, max_subject_length=500,
2783 **kw):
2784 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2785 self.__strict_utf8 = strict_utf8
2786 self.__email_max_line_length = email_max_line_length
2787 self.__max_subject_length = max_subject_length
2789 def filter_body(self, lines):
2790 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2791 if self.__strict_utf8:
2792 if not PYTHON3:
2793 lines = (line.decode(ENCODING, 'replace') for line in lines)
2794 # Limit the line length in Unicode-space to avoid
2795 # splitting characters:
2796 if self.__email_max_line_length > 0:
2797 lines = limit_linelength(lines, self.__email_max_line_length)
2798 if not PYTHON3:
2799 lines = (line.encode(ENCODING, 'replace') for line in lines)
2800 elif self.__email_max_line_length:
2801 lines = limit_linelength(lines, self.__email_max_line_length)
2803 return lines
2805 def get_max_subject_length(self):
2806 return self.__max_subject_length
2809 class ConfigFilterLinesEnvironmentMixin(
2810 ConfigEnvironmentMixin,
2811 FilterLinesEnvironmentMixin,
2813 """Handle encoding and maximum line length based on config."""
2815 def __init__(self, config, **kw):
2816 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2817 if strict_utf8 is not None:
2818 kw['strict_utf8'] = strict_utf8
2820 email_max_line_length = config.get('emailmaxlinelength')
2821 if email_max_line_length is not None:
2822 kw['email_max_line_length'] = int(email_max_line_length)
2824 max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)
2825 if max_subject_length is not None:
2826 kw['max_subject_length'] = int(max_subject_length)
2828 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2829 config=config, **kw
2833 class MaxlinesEnvironmentMixin(Environment):
2834 """Limit the email body to a specified number of lines."""
2836 def __init__(self, emailmaxlines, **kw):
2837 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2838 self.__emailmaxlines = emailmaxlines
2840 def filter_body(self, lines):
2841 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2842 if self.__emailmaxlines > 0:
2843 lines = limit_lines(lines, self.__emailmaxlines)
2844 return lines
2847 class ConfigMaxlinesEnvironmentMixin(
2848 ConfigEnvironmentMixin,
2849 MaxlinesEnvironmentMixin,
2851 """Limit the email body to the number of lines specified in config."""
2853 def __init__(self, config, **kw):
2854 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2855 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2856 config=config,
2857 emailmaxlines=emailmaxlines,
2858 **kw
2862 class FQDNEnvironmentMixin(Environment):
2863 """A mixin that sets the host's FQDN to its constructor argument."""
2865 def __init__(self, fqdn, **kw):
2866 super(FQDNEnvironmentMixin, self).__init__(**kw)
2867 self.COMPUTED_KEYS += ['fqdn']
2868 self.__fqdn = fqdn
2870 def get_fqdn(self):
2871 """Return the fully-qualified domain name for this host.
2873 Return None if it is unavailable or unwanted."""
2875 return self.__fqdn
2878 class ConfigFQDNEnvironmentMixin(
2879 ConfigEnvironmentMixin,
2880 FQDNEnvironmentMixin,
2882 """Read the FQDN from the config."""
2884 def __init__(self, config, **kw):
2885 fqdn = config.get('fqdn')
2886 super(ConfigFQDNEnvironmentMixin, self).__init__(
2887 config=config,
2888 fqdn=fqdn,
2889 **kw
2893 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2894 """Get the FQDN by calling socket.getfqdn()."""
2896 def __init__(self, **kw):
2897 super(ComputeFQDNEnvironmentMixin, self).__init__(
2898 fqdn=socket.getfqdn(),
2899 **kw
2903 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2904 """Deduce pusher_email from pusher by appending an emaildomain."""
2906 def __init__(self, **kw):
2907 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2908 self.__emaildomain = self.config.get('emaildomain')
2910 def get_pusher_email(self):
2911 if self.__emaildomain:
2912 # Derive the pusher's full email address in the default way:
2913 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2914 else:
2915 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2918 class StaticRecipientsEnvironmentMixin(Environment):
2919 """Set recipients statically based on constructor parameters."""
2921 def __init__(
2922 self,
2923 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2924 **kw
2926 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2928 # The recipients for various types of notification emails, as
2929 # RFC 2822 email addresses separated by commas (or the empty
2930 # string if no recipients are configured). Although there is
2931 # a mechanism to choose the recipient lists based on on the
2932 # actual *contents* of the change being reported, we only
2933 # choose based on the *type* of the change. Therefore we can
2934 # compute them once and for all:
2935 self.__refchange_recipients = refchange_recipients
2936 self.__announce_recipients = announce_recipients
2937 self.__revision_recipients = revision_recipients
2939 def check(self):
2940 if not (self.get_refchange_recipients(None) or
2941 self.get_announce_recipients(None) or
2942 self.get_revision_recipients(None) or
2943 self.get_scancommitforcc()):
2944 raise ConfigurationException('No email recipients configured!')
2945 super(StaticRecipientsEnvironmentMixin, self).check()
2947 def get_refchange_recipients(self, refchange):
2948 if self.__refchange_recipients is None:
2949 return super(StaticRecipientsEnvironmentMixin,
2950 self).get_refchange_recipients(refchange)
2951 return self.__refchange_recipients
2953 def get_announce_recipients(self, annotated_tag_change):
2954 if self.__announce_recipients is None:
2955 return super(StaticRecipientsEnvironmentMixin,
2956 self).get_refchange_recipients(annotated_tag_change)
2957 return self.__announce_recipients
2959 def get_revision_recipients(self, revision):
2960 if self.__revision_recipients is None:
2961 return super(StaticRecipientsEnvironmentMixin,
2962 self).get_refchange_recipients(revision)
2963 return self.__revision_recipients
2966 class CLIRecipientsEnvironmentMixin(Environment):
2967 """Mixin storing recipients information comming from the
2968 command-line."""
2970 def __init__(self, cli_recipients=None, **kw):
2971 super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)
2972 self.__cli_recipients = cli_recipients
2974 def get_refchange_recipients(self, refchange):
2975 if self.__cli_recipients is None:
2976 return super(CLIRecipientsEnvironmentMixin,
2977 self).get_refchange_recipients(refchange)
2978 return self.__cli_recipients
2980 def get_announce_recipients(self, annotated_tag_change):
2981 if self.__cli_recipients is None:
2982 return super(CLIRecipientsEnvironmentMixin,
2983 self).get_announce_recipients(annotated_tag_change)
2984 return self.__cli_recipients
2986 def get_revision_recipients(self, revision):
2987 if self.__cli_recipients is None:
2988 return super(CLIRecipientsEnvironmentMixin,
2989 self).get_revision_recipients(revision)
2990 return self.__cli_recipients
2993 class ConfigRecipientsEnvironmentMixin(
2994 ConfigEnvironmentMixin,
2995 StaticRecipientsEnvironmentMixin
2997 """Determine recipients statically based on config."""
2999 def __init__(self, config, **kw):
3000 super(ConfigRecipientsEnvironmentMixin, self).__init__(
3001 config=config,
3002 refchange_recipients=self._get_recipients(
3003 config, 'refchangelist', 'mailinglist',
3005 announce_recipients=self._get_recipients(
3006 config, 'announcelist', 'refchangelist', 'mailinglist',
3008 revision_recipients=self._get_recipients(
3009 config, 'commitlist', 'mailinglist',
3011 scancommitforcc=config.get('scancommitforcc'),
3012 **kw
3015 def _get_recipients(self, config, *names):
3016 """Return the recipients for a particular type of message.
3018 Return the list of email addresses to which a particular type
3019 of notification email should be sent, by looking at the config
3020 value for "multimailhook.$name" for each of names. Use the
3021 value from the first name that is configured. The return
3022 value is a (possibly empty) string containing RFC 2822 email
3023 addresses separated by commas. If no configuration could be
3024 found, raise a ConfigurationException."""
3026 for name in names:
3027 lines = config.get_all(name)
3028 if lines is not None:
3029 lines = [line.strip() for line in lines]
3030 # Single "none" is a special value equivalen to empty string.
3031 if lines == ['none']:
3032 lines = ['']
3033 return ', '.join(lines)
3034 else:
3035 return ''
3038 class StaticRefFilterEnvironmentMixin(Environment):
3039 """Set branch filter statically based on constructor parameters."""
3041 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
3042 ref_filter_do_send_regex, ref_filter_dont_send_regex,
3043 **kw):
3044 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
3046 if ref_filter_incl_regex and ref_filter_excl_regex:
3047 raise ConfigurationException(
3048 "Cannot specify both a ref inclusion and exclusion regex.")
3049 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
3050 default_exclude = self.get_default_ref_ignore_regex()
3051 if ref_filter_incl_regex:
3052 ref_filter_regex = ref_filter_incl_regex
3053 elif ref_filter_excl_regex:
3054 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
3055 else:
3056 ref_filter_regex = default_exclude
3057 try:
3058 self.__compiled_regex = re.compile(ref_filter_regex)
3059 except Exception:
3060 raise ConfigurationException(
3061 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
3063 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
3064 raise ConfigurationException(
3065 "Cannot specify both a ref doSend and dontSend regex.")
3066 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
3067 if ref_filter_do_send_regex:
3068 ref_filter_send_regex = ref_filter_do_send_regex
3069 elif ref_filter_dont_send_regex:
3070 ref_filter_send_regex = ref_filter_dont_send_regex
3071 else:
3072 ref_filter_send_regex = '.*'
3073 self.__is_do_send_filter = True
3074 try:
3075 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
3076 except Exception:
3077 raise ConfigurationException(
3078 'Invalid Ref Filter Regex "%s": %s' %
3079 (ref_filter_send_regex, sys.exc_info()[1]))
3081 def get_ref_filter_regex(self, send_filter=False):
3082 if send_filter:
3083 return self.__send_compiled_regex, self.__is_do_send_filter
3084 else:
3085 return self.__compiled_regex, self.__is_inclusion_filter
3088 class ConfigRefFilterEnvironmentMixin(
3089 ConfigEnvironmentMixin,
3090 StaticRefFilterEnvironmentMixin
3092 """Determine branch filtering statically based on config."""
3094 def _get_regex(self, config, key):
3095 """Get a list of whitespace-separated regex. The refFilter* config
3096 variables are multivalued (hence the use of get_all), and we
3097 allow each entry to be a whitespace-separated list (hence the
3098 split on each line). The whole thing is glued into a single regex."""
3099 values = config.get_all(key)
3100 if values is None:
3101 return values
3102 items = []
3103 for line in values:
3104 for i in line.split():
3105 items.append(i)
3106 if items == []:
3107 return None
3108 return '|'.join(items)
3110 def __init__(self, config, **kw):
3111 super(ConfigRefFilterEnvironmentMixin, self).__init__(
3112 config=config,
3113 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
3114 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
3115 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
3116 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
3117 **kw
3121 class ProjectdescEnvironmentMixin(Environment):
3122 """Make a "projectdesc" value available for templates.
3124 By default, it is set to the first line of $GIT_DIR/description
3125 (if that file is present and appears to be set meaningfully)."""
3127 def __init__(self, **kw):
3128 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3129 self.COMPUTED_KEYS += ['projectdesc']
3131 def get_projectdesc(self):
3132 """Return a one-line descripition of the project."""
3134 git_dir = get_git_dir()
3135 try:
3136 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3137 if projectdesc and not projectdesc.startswith('Unnamed repository'):
3138 return projectdesc
3139 except IOError:
3140 pass
3142 return 'UNNAMED PROJECT'
3145 class GenericEnvironmentMixin(Environment):
3146 def get_pusher(self):
3147 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3150 class GitoliteEnvironmentHighPrecMixin(Environment):
3151 def get_pusher(self):
3152 return self.osenv.get('GL_USER', 'unknown user')
3155 class GitoliteEnvironmentLowPrecMixin(Environment):
3156 def get_repo_shortname(self):
3157 # The gitolite environment variable $GL_REPO is a pretty good
3158 # repo_shortname (though it's probably not as good as a value
3159 # the user might have explicitly put in his config).
3160 return (
3161 self.osenv.get('GL_REPO', None) or
3162 super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()
3165 def get_fromaddr(self, change=None):
3166 GL_USER = self.osenv.get('GL_USER')
3167 if GL_USER is not None:
3168 # Find the path to gitolite.conf. Note that gitolite v3
3169 # did away with the GL_ADMINDIR and GL_CONF environment
3170 # variables (they are now hard-coded).
3171 GL_ADMINDIR = self.osenv.get(
3172 'GL_ADMINDIR',
3173 os.path.expanduser(os.path.join('~', '.gitolite')))
3174 GL_CONF = self.osenv.get(
3175 'GL_CONF',
3176 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3177 if os.path.isfile(GL_CONF):
3178 f = open(GL_CONF, 'rU')
3179 try:
3180 in_user_emails_section = False
3181 re_template = r'^\s*#\s*%s\s*$'
3182 re_begin, re_user, re_end = (
3183 re.compile(re_template % x)
3184 for x in (
3185 r'BEGIN\s+USER\s+EMAILS',
3186 re.escape(GL_USER) + r'\s+(.*)',
3187 r'END\s+USER\s+EMAILS',
3189 for l in f:
3190 l = l.rstrip('\n')
3191 if not in_user_emails_section:
3192 if re_begin.match(l):
3193 in_user_emails_section = True
3194 continue
3195 if re_end.match(l):
3196 break
3197 m = re_user.match(l)
3198 if m:
3199 return m.group(1)
3200 finally:
3201 f.close()
3202 return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)
3205 class IncrementalDateTime(object):
3206 """Simple wrapper to give incremental date/times.
3208 Each call will result in a date/time a second later than the
3209 previous call. This can be used to falsify email headers, to
3210 increase the likelihood that email clients sort the emails
3211 correctly."""
3213 def __init__(self):
3214 self.time = time.time()
3215 self.next = self.__next__ # Python 2 backward compatibility
3217 def __next__(self):
3218 formatted = formatdate(self.time, True)
3219 self.time += 1
3220 return formatted
3223 class StashEnvironmentHighPrecMixin(Environment):
3224 def __init__(self, user=None, repo=None, **kw):
3225 super(StashEnvironmentHighPrecMixin,
3226 self).__init__(user=user, repo=repo, **kw)
3227 self.__user = user
3228 self.__repo = repo
3230 def get_pusher(self):
3231 return re.match('(.*?)\s*<', self.__user).group(1)
3233 def get_pusher_email(self):
3234 return self.__user
3237 class StashEnvironmentLowPrecMixin(Environment):
3238 def __init__(self, user=None, repo=None, **kw):
3239 super(StashEnvironmentLowPrecMixin, self).__init__(**kw)
3240 self.__repo = repo
3241 self.__user = user
3243 def get_repo_shortname(self):
3244 return self.__repo
3246 def get_fromaddr(self, change=None):
3247 return self.__user
3250 class GerritEnvironmentHighPrecMixin(Environment):
3251 def __init__(self, project=None, submitter=None, update_method=None, **kw):
3252 super(GerritEnvironmentHighPrecMixin,
3253 self).__init__(submitter=submitter, project=project, **kw)
3254 self.__project = project
3255 self.__submitter = submitter
3256 self.__update_method = update_method
3257 "Make an 'update_method' value available for templates."
3258 self.COMPUTED_KEYS += ['update_method']
3260 def get_pusher(self):
3261 if self.__submitter:
3262 if self.__submitter.find('<') != -1:
3263 # Submitter has a configured email, we transformed
3264 # __submitter into an RFC 2822 string already.
3265 return re.match('(.*?)\s*<', self.__submitter).group(1)
3266 else:
3267 # Submitter has no configured email, it's just his name.
3268 return self.__submitter
3269 else:
3270 # If we arrive here, this means someone pushed "Submit" from
3271 # the gerrit web UI for the CR (or used one of the programmatic
3272 # APIs to do the same, such as gerrit review) and the
3273 # merge/push was done by the Gerrit user. It was technically
3274 # triggered by someone else, but sadly we have no way of
3275 # determining who that someone else is at this point.
3276 return 'Gerrit' # 'unknown user'?
3278 def get_pusher_email(self):
3279 if self.__submitter:
3280 return self.__submitter
3281 else:
3282 return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email()
3284 def get_default_ref_ignore_regex(self):
3285 default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex()
3286 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3288 def get_revision_recipients(self, revision):
3289 # Merge commits created by Gerrit when users hit "Submit this patchset"
3290 # in the Web UI (or do equivalently with REST APIs or the gerrit review
3291 # command) are not something users want to see an individual email for.
3292 # Filter them out.
3293 committer = read_git_output(['log', '--no-walk', '--format=%cN',
3294 revision.rev.sha1])
3295 if committer == 'Gerrit Code Review':
3296 return []
3297 else:
3298 return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision)
3300 def get_update_method(self):
3301 return self.__update_method
3304 class GerritEnvironmentLowPrecMixin(Environment):
3305 def __init__(self, project=None, submitter=None, **kw):
3306 super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)
3307 self.__project = project
3308 self.__submitter = submitter
3310 def get_repo_shortname(self):
3311 return self.__project
3313 def get_fromaddr(self, change=None):
3314 if self.__submitter and self.__submitter.find('<') != -1:
3315 return self.__submitter
3316 else:
3317 return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change)
3320 class Push(object):
3321 """Represent an entire push (i.e., a group of ReferenceChanges).
3323 It is easy to figure out what commits were added to a *branch* by
3324 a Reference change:
3326 git rev-list change.old..change.new
3328 or removed from a *branch*:
3330 git rev-list change.new..change.old
3332 But it is not quite so trivial to determine which entirely new
3333 commits were added to the *repository* by a push and which old
3334 commits were discarded by a push. A big part of the job of this
3335 class is to figure out these things, and to make sure that new
3336 commits are only detailed once even if they were added to multiple
3337 references.
3339 The first step is to determine the "other" references--those
3340 unaffected by the current push. They are computed by listing all
3341 references then removing any affected by this push. The results
3342 are stored in Push._other_ref_sha1s.
3344 The commits contained in the repository before this push were
3346 git rev-list other1 other2 other3 ... change1.old change2.old ...
3348 Where "changeN.old" is the old value of one of the references
3349 affected by this push.
3351 The commits contained in the repository after this push are
3353 git rev-list other1 other2 other3 ... change1.new change2.new ...
3355 The commits added by this push are the difference between these
3356 two sets, which can be written
3358 git rev-list \
3359 ^other1 ^other2 ... \
3360 ^change1.old ^change2.old ... \
3361 change1.new change2.new ...
3363 The commits removed by this push can be computed by
3365 git rev-list \
3366 ^other1 ^other2 ... \
3367 ^change1.new ^change2.new ... \
3368 change1.old change2.old ...
3370 The last point is that it is possible that other pushes are
3371 occurring simultaneously to this one, so reference values can
3372 change at any time. It is impossible to eliminate all race
3373 conditions, but we reduce the window of time during which problems
3374 can occur by translating reference names to SHA1s as soon as
3375 possible and working with SHA1s thereafter (because SHA1s are
3376 immutable)."""
3378 # A map {(changeclass, changetype): integer} specifying the order
3379 # that reference changes will be processed if multiple reference
3380 # changes are included in a single push. The order is significant
3381 # mostly because new commit notifications are threaded together
3382 # with the first reference change that includes the commit. The
3383 # following order thus causes commits to be grouped with branch
3384 # changes (as opposed to tag changes) if possible.
3385 SORT_ORDER = dict(
3386 (value, i) for (i, value) in enumerate([
3387 (BranchChange, 'update'),
3388 (BranchChange, 'create'),
3389 (AnnotatedTagChange, 'update'),
3390 (AnnotatedTagChange, 'create'),
3391 (NonAnnotatedTagChange, 'update'),
3392 (NonAnnotatedTagChange, 'create'),
3393 (BranchChange, 'delete'),
3394 (AnnotatedTagChange, 'delete'),
3395 (NonAnnotatedTagChange, 'delete'),
3396 (OtherReferenceChange, 'update'),
3397 (OtherReferenceChange, 'create'),
3398 (OtherReferenceChange, 'delete'),
3402 def __init__(self, environment, changes, ignore_other_refs=False):
3403 self.changes = sorted(changes, key=self._sort_key)
3404 self.__other_ref_sha1s = None
3405 self.__cached_commits_spec = {}
3406 self.environment = environment
3408 if ignore_other_refs:
3409 self.__other_ref_sha1s = set()
3411 @classmethod
3412 def _sort_key(klass, change):
3413 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3415 @property
3416 def _other_ref_sha1s(self):
3417 """The GitObjects referred to by references unaffected by this push.
3419 if self.__other_ref_sha1s is None:
3420 # The refnames being changed by this push:
3421 updated_refs = set(
3422 change.refname
3423 for change in self.changes
3426 # The SHA-1s of commits referred to by all references in this
3427 # repository *except* updated_refs:
3428 sha1s = set()
3429 fmt = (
3430 '%(objectname) %(objecttype) %(refname)\n'
3431 '%(*objectname) %(*objecttype) %(refname)'
3433 ref_filter_regex, is_inclusion_filter = \
3434 self.environment.get_ref_filter_regex()
3435 for line in read_git_lines(
3436 ['for-each-ref', '--format=%s' % (fmt,)]):
3437 (sha1, type, name) = line.split(' ', 2)
3438 if (sha1 and type == 'commit' and
3439 name not in updated_refs and
3440 include_ref(name, ref_filter_regex, is_inclusion_filter)):
3441 sha1s.add(sha1)
3443 self.__other_ref_sha1s = sha1s
3445 return self.__other_ref_sha1s
3447 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3448 """Get new or old SHA-1 from one or each of the changed refs.
3450 Return a list of SHA-1 commit identifier strings suitable as
3451 arguments to 'git rev-list' (or 'git log' or ...). The
3452 returned identifiers are either the old or new values from one
3453 or all of the changed references, depending on the values of
3454 new_or_old and reference_change.
3456 new_or_old is either the string 'new' or the string 'old'. If
3457 'new', the returned SHA-1 identifiers are the new values from
3458 each changed reference. If 'old', the SHA-1 identifiers are
3459 the old values from each changed reference.
3461 If reference_change is specified and not None, only the new or
3462 old reference from the specified reference is included in the
3463 return value.
3465 This function returns None if there are no matching revisions
3466 (e.g., because a branch was deleted and new_or_old is 'new').
3469 if not reference_change:
3470 incl_spec = sorted(
3471 getattr(change, new_or_old).sha1
3472 for change in self.changes
3473 if getattr(change, new_or_old)
3475 if not incl_spec:
3476 incl_spec = None
3477 elif not getattr(reference_change, new_or_old).commit_sha1:
3478 incl_spec = None
3479 else:
3480 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3481 return incl_spec
3483 def _get_commits_spec_excl(self, new_or_old):
3484 """Get exclusion revisions for determining new or discarded commits.
3486 Return a list of strings suitable as arguments to 'git
3487 rev-list' (or 'git log' or ...) that will exclude all
3488 commits that, depending on the value of new_or_old, were
3489 either previously in the repository (useful for determining
3490 which commits are new to the repository) or currently in the
3491 repository (useful for determining which commits were
3492 discarded from the repository).
3494 new_or_old is either the string 'new' or the string 'old'. If
3495 'new', the commits to be excluded are those that were in the
3496 repository before the push. If 'old', the commits to be
3497 excluded are those that are currently in the repository. """
3499 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3500 excl_revs = self._other_ref_sha1s.union(
3501 getattr(change, old_or_new).sha1
3502 for change in self.changes
3503 if getattr(change, old_or_new).type in ['commit', 'tag']
3505 return ['^' + sha1 for sha1 in sorted(excl_revs)]
3507 def get_commits_spec(self, new_or_old, reference_change=None):
3508 """Get rev-list arguments for added or discarded commits.
3510 Return a list of strings suitable as arguments to 'git
3511 rev-list' (or 'git log' or ...) that select those commits
3512 that, depending on the value of new_or_old, are either new to
3513 the repository or were discarded from the repository.
3515 new_or_old is either the string 'new' or the string 'old'. If
3516 'new', the returned list is used to select commits that are
3517 new to the repository. If 'old', the returned value is used
3518 to select the commits that have been discarded from the
3519 repository.
3521 If reference_change is specified and not None, the new or
3522 discarded commits are limited to those that are reachable from
3523 the new or old value of the specified reference.
3525 This function returns None if there are no added (or discarded)
3526 revisions.
3528 key = (new_or_old, reference_change)
3529 if key not in self.__cached_commits_spec:
3530 ret = self._get_commits_spec_incl(new_or_old, reference_change)
3531 if ret is not None:
3532 ret.extend(self._get_commits_spec_excl(new_or_old))
3533 self.__cached_commits_spec[key] = ret
3534 return self.__cached_commits_spec[key]
3536 def get_new_commits(self, reference_change=None):
3537 """Return a list of commits added by this push.
3539 Return a list of the object names of commits that were added
3540 by the part of this push represented by reference_change. If
3541 reference_change is None, then return a list of *all* commits
3542 added by this push."""
3544 spec = self.get_commits_spec('new', reference_change)
3545 return git_rev_list(spec)
3547 def get_discarded_commits(self, reference_change):
3548 """Return a list of commits discarded by this push.
3550 Return a list of the object names of commits that were
3551 entirely discarded from the repository by the part of this
3552 push represented by reference_change."""
3554 spec = self.get_commits_spec('old', reference_change)
3555 return git_rev_list(spec)
3557 def send_emails(self, mailer, body_filter=None):
3558 """Use send all of the notification emails needed for this push.
3560 Use send all of the notification emails (including reference
3561 change emails and commit emails) needed for this push. Send
3562 the emails using mailer. If body_filter is not None, then use
3563 it to filter the lines that are intended for the email
3564 body."""
3566 # The sha1s of commits that were introduced by this push.
3567 # They will be removed from this set as they are processed, to
3568 # guarantee that one (and only one) email is generated for
3569 # each new commit.
3570 unhandled_sha1s = set(self.get_new_commits())
3571 send_date = IncrementalDateTime()
3572 for change in self.changes:
3573 sha1s = []
3574 for sha1 in reversed(list(self.get_new_commits(change))):
3575 if sha1 in unhandled_sha1s:
3576 sha1s.append(sha1)
3577 unhandled_sha1s.remove(sha1)
3579 # Check if we've got anyone to send to
3580 if not change.recipients:
3581 change.environment.log_warning(
3582 '*** no recipients configured so no email will be sent\n'
3583 '*** for %r update %s->%s'
3584 % (change.refname, change.old.sha1, change.new.sha1,)
3586 else:
3587 if not change.environment.quiet:
3588 change.environment.log_msg(
3589 'Sending notification emails to: %s' % (change.recipients,))
3590 extra_values = {'send_date': next(send_date)}
3592 rev = change.send_single_combined_email(sha1s)
3593 if rev:
3594 mailer.send(
3595 change.generate_combined_email(self, rev, body_filter, extra_values),
3596 rev.recipients,
3598 # This change is now fully handled; no need to handle
3599 # individual revisions any further.
3600 continue
3601 else:
3602 mailer.send(
3603 change.generate_email(self, body_filter, extra_values),
3604 change.recipients,
3607 max_emails = change.environment.maxcommitemails
3608 if max_emails and len(sha1s) > max_emails:
3609 change.environment.log_warning(
3610 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3611 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3612 '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails
3614 return
3616 for (num, sha1) in enumerate(sha1s):
3617 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3618 if not rev.recipients and rev.cc_recipients:
3619 change.environment.log_msg('*** Replacing Cc: with To:')
3620 rev.recipients = rev.cc_recipients
3621 rev.cc_recipients = None
3622 if rev.recipients:
3623 extra_values = {'send_date': next(send_date)}
3624 mailer.send(
3625 rev.generate_email(self, body_filter, extra_values),
3626 rev.recipients,
3629 # Consistency check:
3630 if unhandled_sha1s:
3631 change.environment.log_error(
3632 'ERROR: No emails were sent for the following new commits:\n'
3633 ' %s'
3634 % ('\n '.join(sorted(unhandled_sha1s)),)
3638 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3639 does_match = bool(ref_filter_regex.search(refname))
3640 if is_inclusion_filter:
3641 return does_match
3642 else: # exclusion filter -- we include the ref if the regex doesn't match
3643 return not does_match
3646 def run_as_post_receive_hook(environment, mailer):
3647 environment.check()
3648 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3649 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3650 changes = []
3651 while True:
3652 line = read_line(sys.stdin)
3653 if line == '':
3654 break
3655 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3656 environment.get_logger().debug(
3657 "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" %
3658 (oldrev, newrev, refname))
3660 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3661 continue
3662 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3663 continue
3664 changes.append(
3665 ReferenceChange.create(environment, oldrev, newrev, refname)
3667 if changes:
3668 push = Push(environment, changes)
3669 push.send_emails(mailer, body_filter=environment.filter_body)
3670 if hasattr(mailer, '__del__'):
3671 mailer.__del__()
3674 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3675 environment.check()
3676 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3677 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3678 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3679 return
3680 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3681 return
3682 changes = [
3683 ReferenceChange.create(
3684 environment,
3685 read_git_output(['rev-parse', '--verify', oldrev]),
3686 read_git_output(['rev-parse', '--verify', newrev]),
3687 refname,
3690 push = Push(environment, changes, force_send)
3691 push.send_emails(mailer, body_filter=environment.filter_body)
3692 if hasattr(mailer, '__del__'):
3693 mailer.__del__()
3696 def check_ref_filter(environment):
3697 send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True)
3698 ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False)
3700 def inc_exc_lusion(b):
3701 if b:
3702 return 'inclusion'
3703 else:
3704 return 'exclusion'
3706 if send_filter_regex:
3707 sys.stdout.write("DoSend/DontSend filter regex (" +
3708 (inc_exc_lusion(send_is_inclusion)) +
3709 '): ' + send_filter_regex.pattern +
3710 '\n')
3711 if send_filter_regex:
3712 sys.stdout.write("Include/Exclude filter regex (" +
3713 (inc_exc_lusion(ref_is_inclusion)) +
3714 '): ' + ref_filter_regex.pattern +
3715 '\n')
3716 sys.stdout.write(os.linesep)
3718 sys.stdout.write(
3719 "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"
3720 "or refFilterExclusionRegex. No emails will be sent for commits included\n"
3721 "in these refs.\n"
3722 "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"
3723 "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"
3724 "refFilterExclusionRegex. Emails will be sent for commits included in these\n"
3725 "refs only when the commit reaches a ref which isn't excluded.\n"
3726 "Refs marked as DO-SEND are not excluded by any filter. Emails will\n"
3727 "be sent normally for commits included in these refs.\n")
3729 sys.stdout.write(os.linesep)
3731 for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']):
3732 sys.stdout.write(refname)
3733 if not include_ref(refname, ref_filter_regex, ref_is_inclusion):
3734 sys.stdout.write(' EXCLUDE')
3735 elif not include_ref(refname, send_filter_regex, send_is_inclusion):
3736 sys.stdout.write(' DONT-SEND')
3737 else:
3738 sys.stdout.write(' DO-SEND')
3740 sys.stdout.write(os.linesep)
3743 def show_env(environment, out):
3744 out.write('Environment values:\n')
3745 for (k, v) in sorted(environment.get_values().items()):
3746 if k: # Don't show the {'' : ''} pair.
3747 out.write(' %s : %r\n' % (k, v))
3748 out.write('\n')
3749 # Flush to avoid interleaving with further log output
3750 out.flush()
3753 def check_setup(environment):
3754 environment.check()
3755 show_env(environment, sys.stdout)
3756 sys.stdout.write("Now, checking that git-multimail's standard input "
3757 "is properly set ..." + os.linesep)
3758 sys.stdout.write("Please type some text and then press Return" + os.linesep)
3759 stdin = sys.stdin.readline()
3760 sys.stdout.write("You have just entered:" + os.linesep)
3761 sys.stdout.write(stdin)
3762 sys.stdout.write("git-multimail seems properly set up." + os.linesep)
3765 def choose_mailer(config, environment):
3766 mailer = config.get('mailer', default='sendmail')
3768 if mailer == 'smtp':
3769 smtpserver = config.get('smtpserver', default='localhost')
3770 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3771 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3772 smtpencryption = config.get('smtpencryption', default='none')
3773 smtpuser = config.get('smtpuser', default='')
3774 smtppass = config.get('smtppass', default='')
3775 smtpcacerts = config.get('smtpcacerts', default='')
3776 mailer = SMTPMailer(
3777 environment,
3778 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3779 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3780 smtpserverdebuglevel=smtpserverdebuglevel,
3781 smtpencryption=smtpencryption,
3782 smtpuser=smtpuser,
3783 smtppass=smtppass,
3784 smtpcacerts=smtpcacerts
3786 elif mailer == 'sendmail':
3787 command = config.get('sendmailcommand')
3788 if command:
3789 command = shlex.split(command)
3790 mailer = SendMailer(environment,
3791 command=command, envelopesender=environment.get_sender())
3792 else:
3793 environment.log_error(
3794 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3795 'please use one of "smtp" or "sendmail".'
3797 sys.exit(1)
3798 return mailer
3801 KNOWN_ENVIRONMENTS = {
3802 'generic': {'highprec': GenericEnvironmentMixin},
3803 'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin,
3804 'lowprec': GitoliteEnvironmentLowPrecMixin},
3805 'stash': {'highprec': StashEnvironmentHighPrecMixin,
3806 'lowprec': StashEnvironmentLowPrecMixin},
3807 'gerrit': {'highprec': GerritEnvironmentHighPrecMixin,
3808 'lowprec': GerritEnvironmentLowPrecMixin},
3812 def choose_environment(config, osenv=None, env=None, recipients=None,
3813 hook_info=None):
3814 env_name = choose_environment_name(config, env, osenv)
3815 environment_klass = build_environment_klass(env_name)
3816 env = build_environment(environment_klass, env_name, config,
3817 osenv, recipients, hook_info)
3818 return env
3821 def choose_environment_name(config, env, osenv):
3822 if not osenv:
3823 osenv = os.environ
3825 if not env:
3826 env = config.get('environment')
3828 if not env:
3829 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3830 env = 'gitolite'
3831 else:
3832 env = 'generic'
3833 return env
3836 COMMON_ENVIRONMENT_MIXINS = [
3837 ConfigRecipientsEnvironmentMixin,
3838 CLIRecipientsEnvironmentMixin,
3839 ConfigRefFilterEnvironmentMixin,
3840 ProjectdescEnvironmentMixin,
3841 ConfigMaxlinesEnvironmentMixin,
3842 ComputeFQDNEnvironmentMixin,
3843 ConfigFilterLinesEnvironmentMixin,
3844 PusherDomainEnvironmentMixin,
3845 ConfigOptionsEnvironmentMixin,
3849 def build_environment_klass(env_name):
3850 if 'class' in KNOWN_ENVIRONMENTS[env_name]:
3851 return KNOWN_ENVIRONMENTS[env_name]['class']
3853 environment_mixins = []
3854 known_env = KNOWN_ENVIRONMENTS[env_name]
3855 if 'highprec' in known_env:
3856 high_prec_mixin = known_env['highprec']
3857 environment_mixins.append(high_prec_mixin)
3858 environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS
3859 if 'lowprec' in known_env:
3860 low_prec_mixin = known_env['lowprec']
3861 environment_mixins.append(low_prec_mixin)
3862 environment_mixins.append(Environment)
3863 klass_name = env_name.capitalize() + 'Environement'
3864 environment_klass = type(
3865 klass_name,
3866 tuple(environment_mixins),
3869 KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass
3870 return environment_klass
3873 GerritEnvironment = build_environment_klass('gerrit')
3874 StashEnvironment = build_environment_klass('stash')
3875 GitoliteEnvironment = build_environment_klass('gitolite')
3876 GenericEnvironment = build_environment_klass('generic')
3879 def build_environment(environment_klass, env, config,
3880 osenv, recipients, hook_info):
3881 environment_kw = {
3882 'osenv': osenv,
3883 'config': config,
3886 if env == 'stash':
3887 environment_kw['user'] = hook_info['stash_user']
3888 environment_kw['repo'] = hook_info['stash_repo']
3889 elif env == 'gerrit':
3890 environment_kw['project'] = hook_info['project']
3891 environment_kw['submitter'] = hook_info['submitter']
3892 environment_kw['update_method'] = hook_info['update_method']
3894 environment_kw['cli_recipients'] = recipients
3896 return environment_klass(**environment_kw)
3899 def get_version():
3900 oldcwd = os.getcwd()
3901 try:
3902 try:
3903 os.chdir(os.path.dirname(os.path.realpath(__file__)))
3904 git_version = read_git_output(['describe', '--tags', 'HEAD'])
3905 if git_version == __version__:
3906 return git_version
3907 else:
3908 return '%s (%s)' % (__version__, git_version)
3909 except:
3910 pass
3911 finally:
3912 os.chdir(oldcwd)
3913 return __version__
3916 def compute_gerrit_options(options, args, required_gerrit_options,
3917 raw_refname):
3918 if None in required_gerrit_options:
3919 raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3920 "and --project; or none of them.")
3922 if options.environment not in (None, 'gerrit'):
3923 raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3924 "--newrev, --refname, and --project")
3925 options.environment = 'gerrit'
3927 if args:
3928 raise SystemExit("Error: Positional parameters not allowed with "
3929 "--oldrev, --newrev, and --refname.")
3931 # Gerrit oddly omits 'refs/heads/' in the refname when calling
3932 # ref-updated hook; put it back.
3933 git_dir = get_git_dir()
3934 if (not os.path.exists(os.path.join(git_dir, raw_refname)) and
3935 os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3936 raw_refname))):
3937 options.refname = 'refs/heads/' + options.refname
3939 # New revisions can appear in a gerrit repository either due to someone
3940 # pushing directly (in which case options.submitter will be set), or they
3941 # can press "Submit this patchset" in the web UI for some CR (in which
3942 # case options.submitter will not be set and gerrit will not have provided
3943 # us the information about who pressed the button).
3945 # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3946 # gerrit review command in with "Submit this patchset" button, since they
3947 # have the same effect.
3948 if options.submitter:
3949 update_method = 'pushed'
3950 # The submitter argument is almost an RFC 2822 email address; change it
3951 # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3952 options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3953 else:
3954 update_method = 'submitted'
3955 # Gerrit knew who submitted this patchset, but threw that information
3956 # away when it invoked this hook. However, *IF* Gerrit created a
3957 # merge to bring the patchset in (project 'Submit Type' is either
3958 # "Always Merge", or is "Merge if Necessary" and happens to be
3959 # necessary for this particular CR), then it will have the committer
3960 # of that merge be 'Gerrit Code Review' and the author will be the
3961 # person who requested the submission of the CR. Since this is fairly
3962 # likely for most gerrit installations (of a reasonable size), it's
3963 # worth the extra effort to try to determine the actual submitter.
3964 rev_info = read_git_lines(['log', '--no-walk', '--merges',
3965 '--format=%cN%n%aN <%aE>', options.newrev])
3966 if rev_info and rev_info[0] == 'Gerrit Code Review':
3967 options.submitter = rev_info[1]
3969 # We pass back refname, oldrev, newrev as args because then the
3970 # gerrit ref-updated hook is much like the git update hook
3971 return (options,
3972 [options.refname, options.oldrev, options.newrev],
3973 {'project': options.project, 'submitter': options.submitter,
3974 'update_method': update_method})
3977 def check_hook_specific_args(options, args):
3978 raw_refname = options.refname
3979 # Convert each string option unicode for Python3.
3980 if PYTHON3:
3981 opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3982 'project', 'submitter', 'stash_user', 'stash_repo']
3983 for opt in opts:
3984 if not hasattr(options, opt):
3985 continue
3986 obj = getattr(options, opt)
3987 if obj:
3988 enc = obj.encode('utf-8', 'surrogateescape')
3989 dec = enc.decode('utf-8', 'replace')
3990 setattr(options, opt, dec)
3992 # First check for stash arguments
3993 if (options.stash_user is None) != (options.stash_repo is None):
3994 raise SystemExit("Error: Specify both of --stash-user and "
3995 "--stash-repo or neither.")
3996 if options.stash_user:
3997 options.environment = 'stash'
3998 return options, args, {'stash_user': options.stash_user,
3999 'stash_repo': options.stash_repo}
4001 # Finally, check for gerrit specific arguments
4002 required_gerrit_options = (options.oldrev, options.newrev, options.refname,
4003 options.project)
4004 if required_gerrit_options != (None,) * 4:
4005 return compute_gerrit_options(options, args, required_gerrit_options,
4006 raw_refname)
4008 # No special options in use, just return what we started with
4009 return options, args, {}
4012 class Logger(object):
4013 def parse_verbose(self, verbose):
4014 if verbose > 0:
4015 return logging.DEBUG
4016 else:
4017 return logging.INFO
4019 def create_log_file(self, environment, name, path, verbosity):
4020 log_file = logging.getLogger(name)
4021 file_handler = logging.FileHandler(path)
4022 log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s")
4023 file_handler.setFormatter(log_fmt)
4024 log_file.addHandler(file_handler)
4025 log_file.setLevel(verbosity)
4026 return log_file
4028 def __init__(self, environment):
4029 self.environment = environment
4030 self.loggers = []
4031 stderr_log = logging.getLogger('git_multimail.stderr')
4033 class EncodedStderr(object):
4034 def write(self, x):
4035 write_str(sys.stderr, x)
4037 def flush(self):
4038 sys.stderr.flush()
4040 stderr_handler = logging.StreamHandler(EncodedStderr())
4041 stderr_log.addHandler(stderr_handler)
4042 stderr_log.setLevel(self.parse_verbose(environment.verbose))
4043 self.loggers.append(stderr_log)
4045 if environment.debug_log_file is not None:
4046 debug_log_file = self.create_log_file(
4047 environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG)
4048 self.loggers.append(debug_log_file)
4050 if environment.log_file is not None:
4051 log_file = self.create_log_file(
4052 environment, 'git_multimail.file', environment.log_file, logging.INFO)
4053 self.loggers.append(log_file)
4055 if environment.error_log_file is not None:
4056 error_log_file = self.create_log_file(
4057 environment, 'git_multimail.error', environment.error_log_file, logging.ERROR)
4058 self.loggers.append(error_log_file)
4060 def info(self, msg):
4061 for l in self.loggers:
4062 l.info(msg)
4064 def debug(self, msg):
4065 for l in self.loggers:
4066 l.debug(msg)
4068 def warning(self, msg):
4069 for l in self.loggers:
4070 l.warning(msg)
4072 def error(self, msg):
4073 for l in self.loggers:
4074 l.error(msg)
4077 def main(args):
4078 parser = optparse.OptionParser(
4079 description=__doc__,
4080 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
4083 parser.add_option(
4084 '--environment', '--env', action='store', type='choice',
4085 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
4086 help=(
4087 'Choose type of environment is in use. Default is taken from '
4088 'multimailhook.environment if set; otherwise "generic".'
4091 parser.add_option(
4092 '--stdout', action='store_true', default=False,
4093 help='Output emails to stdout rather than sending them.',
4095 parser.add_option(
4096 '--recipients', action='store', default=None,
4097 help='Set list of email recipients for all types of emails.',
4099 parser.add_option(
4100 '--show-env', action='store_true', default=False,
4101 help=(
4102 'Write to stderr the values determined for the environment '
4103 '(intended for debugging purposes), then proceed normally.'
4106 parser.add_option(
4107 '--force-send', action='store_true', default=False,
4108 help=(
4109 'Force sending refchange email when using as an update hook. '
4110 'This is useful to work around the unreliable new commits '
4111 'detection in this mode.'
4114 parser.add_option(
4115 '-c', metavar="<name>=<value>", action='append',
4116 help=(
4117 'Pass a configuration parameter through to git. The value given '
4118 'will override values from configuration files. See the -c option '
4119 'of git(1) for more details. (Only works with git >= 1.7.3)'
4122 parser.add_option(
4123 '--version', '-v', action='store_true', default=False,
4124 help=(
4125 "Display git-multimail's version"
4129 parser.add_option(
4130 '--python-version', action='store_true', default=False,
4131 help=(
4132 "Display the version of Python used by git-multimail"
4136 parser.add_option(
4137 '--check-ref-filter', action='store_true', default=False,
4138 help=(
4139 'List refs and show information on how git-multimail '
4140 'will process them.'
4144 # The following options permit this script to be run as a gerrit
4145 # ref-updated hook. See e.g.
4146 # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
4147 # We suppress help for these items, since these are specific to gerrit,
4148 # and we don't want users directly using them any way other than how the
4149 # gerrit ref-updated hook is called.
4150 parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
4151 parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
4152 parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
4153 parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
4154 parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
4156 # The following allow this to be run as a stash asynchronous post-receive
4157 # hook (almost identical to a git post-receive hook but triggered also for
4158 # merges of pull requests from the UI). We suppress help for these items,
4159 # since these are specific to stash.
4160 parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
4161 parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
4163 (options, args) = parser.parse_args(args)
4164 (options, args, hook_info) = check_hook_specific_args(options, args)
4166 if options.version:
4167 sys.stdout.write('git-multimail version ' + get_version() + '\n')
4168 return
4170 if options.python_version:
4171 sys.stdout.write('Python version ' + sys.version + '\n')
4172 return
4174 if options.c:
4175 Config.add_config_parameters(options.c)
4177 config = Config('multimailhook')
4179 environment = None
4180 try:
4181 environment = choose_environment(
4182 config, osenv=os.environ,
4183 env=options.environment,
4184 recipients=options.recipients,
4185 hook_info=hook_info,
4188 if options.show_env:
4189 show_env(environment, sys.stderr)
4191 if options.stdout or environment.stdout:
4192 mailer = OutputMailer(sys.stdout)
4193 else:
4194 mailer = choose_mailer(config, environment)
4196 must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP')
4197 if must_check_setup == '':
4198 must_check_setup = False
4199 if options.check_ref_filter:
4200 check_ref_filter(environment)
4201 elif must_check_setup:
4202 check_setup(environment)
4203 # Dual mode: if arguments were specified on the command line, run
4204 # like an update hook; otherwise, run as a post-receive hook.
4205 elif args:
4206 if len(args) != 3:
4207 parser.error('Need zero or three non-option arguments')
4208 (refname, oldrev, newrev) = args
4209 environment.get_logger().debug(
4210 "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" %
4211 (refname, oldrev, newrev, options.force_send))
4212 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
4213 else:
4214 run_as_post_receive_hook(environment, mailer)
4215 except ConfigurationException:
4216 sys.exit(sys.exc_info()[1])
4217 except SystemExit:
4218 raise
4219 except Exception:
4220 t, e, tb = sys.exc_info()
4221 import traceback
4222 sys.stderr.write('\n') # Avoid mixing message with previous output
4223 msg = (
4224 'Exception \'' + t.__name__ +
4225 '\' raised. Please report this as a bug to\n'
4226 'https://github.com/git-multimail/git-multimail/issues\n'
4227 'with the information below:\n\n'
4228 'git-multimail version ' + get_version() + '\n'
4229 'Python version ' + sys.version + '\n' +
4230 traceback.format_exc())
4231 try:
4232 environment.get_logger().error(msg)
4233 except:
4234 sys.stderr.write(msg)
4235 sys.exit(1)
4237 if __name__ == '__main__':
4238 main(sys.argv[1:])