Merge branch 'jk/config-type-color-ends-with-lf'
[git/git.git] / contrib / hooks / multimail / git_multimail.py
CommitLineData
4b1fd356
MM
1#! /usr/bin/env python
2
99177b34 3__version__ = '1.5.0'
bc501f69 4
7c554311 5# Copyright (c) 2015-2016 Matthieu Moy and others
b513f71f 6# Copyright (c) 2012-2014 Michael Haggerty and others
bc501f69
MH
7# Derived from contrib/hooks/post-receive-email, which is
8# Copyright (c) 2007 Andy Parkins
9# and also includes contributions by other authors.
10#
11# This file is part of git-multimail.
12#
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.
16#
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.
21#
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/>.
25
26"""Generate notification emails for pushes to a git repository.
27
28This hook sends emails describing changes introduced by pushes to a
29git repository. For each reference that was changed, it emits one
30ReferenceChange email summarizing how the reference was changed,
31followed by one Revision email for each new commit that was introduced
32by the reference change.
33
34Each commit is announced in exactly one Revision email. If the same
35commit is merged into another branch in the same or a later push, then
36the ReferenceChange email will list the commit's SHA1 and its one-line
37summary, but no new Revision email will be generated.
38
39This script is designed to be used as a "post-receive" hook in a git
40repository (see githooks(5)). It can also be used as an "update"
41script, but this usage is not completely reliable and is deprecated.
42
43To help with debugging, this script accepts a --stdout option, which
44causes the emails to be written to standard output rather than sent
45using sendmail.
46
47See the accompanying README file for the complete documentation.
48
49"""
50
51import sys
52import os
53import re
54import bisect
b513f71f 55import socket
bc501f69
MH
56import subprocess
57import shlex
58import optparse
7c554311 59import logging
bc501f69 60import smtplib
4453d76c
MM
61try:
62 import ssl
63except ImportError:
64 # Python < 2.6 do not have ssl, but that's OK if we don't use it.
65 pass
b513f71f 66import time
99177b34
MM
67
68import uuid
69import base64
4b1fd356
MM
70
71PYTHON3 = sys.version_info >= (3, 0)
72
73if sys.version_info <= (2, 5):
74 def all(iterable):
75 for element in iterable:
76 if not element:
77 return False
99177b34 78 return True
4b1fd356
MM
79
80
81def is_ascii(s):
82 return all(ord(c) < 128 and ord(c) > 0 for c in s)
83
84
85if PYTHON3:
4453d76c
MM
86 def is_string(s):
87 return isinstance(s, str)
88
4b1fd356
MM
89 def str_to_bytes(s):
90 return s.encode(ENCODING)
91
7c554311
MM
92 def bytes_to_str(s, errors='strict'):
93 return s.decode(ENCODING, errors)
4b1fd356
MM
94
95 unicode = str
96
97 def write_str(f, msg):
98 # Try outputing with the default encoding. If it fails,
99 # try UTF-8.
100 try:
101 f.buffer.write(msg.encode(sys.getdefaultencoding()))
102 except UnicodeEncodeError:
103 f.buffer.write(msg.encode(ENCODING))
7c554311
MM
104
105 def read_line(f):
106 # Try reading with the default encoding. If it fails,
107 # try UTF-8.
108 out = f.buffer.readline()
109 try:
110 return out.decode(sys.getdefaultencoding())
111 except UnicodeEncodeError:
112 return out.decode(ENCODING)
99177b34
MM
113
114 import html
115
116 def html_escape(s):
117 return html.escape(s)
118
4b1fd356 119else:
4453d76c
MM
120 def is_string(s):
121 try:
122 return isinstance(s, basestring)
123 except NameError: # Silence Pyflakes warning
124 raise
125
4b1fd356
MM
126 def str_to_bytes(s):
127 return s
128
7c554311 129 def bytes_to_str(s, errors='strict'):
4b1fd356
MM
130 return s
131
132 def write_str(f, msg):
133 f.write(msg)
134
7c554311
MM
135 def read_line(f):
136 return f.readline()
137
4b1fd356
MM
138 def next(it):
139 return it.next()
140
99177b34
MM
141 import cgi
142
143 def html_escape(s):
144 return cgi.escape(s, True)
bc501f69
MH
145
146try:
4b1fd356 147 from email.charset import Charset
bc501f69
MH
148 from email.utils import make_msgid
149 from email.utils import getaddresses
150 from email.utils import formataddr
b513f71f 151 from email.utils import formatdate
bc501f69
MH
152 from email.header import Header
153except ImportError:
154 # Prior to Python 2.5, the email module used different names:
4b1fd356 155 from email.Charset import Charset
bc501f69
MH
156 from email.Utils import make_msgid
157 from email.Utils import getaddresses
158 from email.Utils import formataddr
b513f71f 159 from email.Utils import formatdate
bc501f69
MH
160 from email.Header import Header
161
162
163DEBUG = False
164
165ZEROS = '0' * 40
166LOGBEGIN = '- Log -----------------------------------------------------------------\n'
167LOGEND = '-----------------------------------------------------------------------\n'
168
b513f71f 169ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
bc501f69
MH
170
171# It is assumed in many places that the encoding is uniformly UTF-8,
172# so changing these constants is unsupported. But define them here
173# anyway, to make it easier to find (at least most of) the places
174# where the encoding is important.
175(ENCODING, CHARSET) = ('UTF-8', 'utf-8')
176
177
178REF_CREATED_SUBJECT_TEMPLATE = (
179 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
180 ' (now %(newrev_short)s)'
181 )
182REF_UPDATED_SUBJECT_TEMPLATE = (
183 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
184 ' (%(oldrev_short)s -> %(newrev_short)s)'
185 )
186REF_DELETED_SUBJECT_TEMPLATE = (
187 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
188 ' (was %(oldrev_short)s)'
189 )
190
5b1d901c
MM
191COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
192 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
193 )
194
bc501f69 195REFCHANGE_HEADER_TEMPLATE = """\
b513f71f 196Date: %(send_date)s
bc501f69
MH
197To: %(recipients)s
198Subject: %(subject)s
199MIME-Version: 1.0
4b1fd356 200Content-Type: text/%(contenttype)s; charset=%(charset)s
bc501f69
MH
201Content-Transfer-Encoding: 8bit
202Message-ID: %(msgid)s
203From: %(fromaddr)s
204Reply-To: %(reply_to)s
99177b34 205Thread-Index: %(thread_index)s
b513f71f 206X-Git-Host: %(fqdn)s
bc501f69
MH
207X-Git-Repo: %(repo_shortname)s
208X-Git-Refname: %(refname)s
209X-Git-Reftype: %(refname_type)s
210X-Git-Oldrev: %(oldrev)s
211X-Git-Newrev: %(newrev)s
4b1fd356
MM
212X-Git-NotificationType: ref_changed
213X-Git-Multimail-Version: %(multimail_version)s
bc501f69
MH
214Auto-Submitted: auto-generated
215"""
216
217REFCHANGE_INTRO_TEMPLATE = """\
218This is an automated email from the git hooks/post-receive script.
219
220%(pusher)s pushed a change to %(refname_type)s %(short_refname)s
221in repository %(repo_shortname)s.
222
223"""
224
225
226FOOTER_TEMPLATE = """\
227
228-- \n\
229To stop receiving notification emails like this one, please contact
230%(administrator)s.
231"""
232
233
234REWIND_ONLY_TEMPLATE = """\
235This update removed existing revisions from the reference, leaving the
236reference pointing at a previous point in the repository history.
237
238 * -- * -- N %(refname)s (%(newrev_short)s)
239 \\
240 O -- O -- O (%(oldrev_short)s)
241
7c554311
MM
242Any revisions marked "omit" are not gone; other references still
243refer to them. Any revisions marked "discard" are gone forever.
bc501f69
MH
244"""
245
246
247NON_FF_TEMPLATE = """\
248This update added new revisions after undoing existing revisions.
249That is to say, some revisions that were in the old version of the
250%(refname_type)s are not in the new version. This situation occurs
251when a user --force pushes a change and generates a repository
252containing something like this:
253
254 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
255 \\
256 N -- N -- N %(refname)s (%(newrev_short)s)
257
258You should already have received notification emails for all of the O
259revisions, and so the following emails describe only the N revisions
260from the common base, B.
261
7c554311
MM
262Any revisions marked "omit" are not gone; other references still
263refer to them. Any revisions marked "discard" are gone forever.
bc501f69
MH
264"""
265
266
267NO_NEW_REVISIONS_TEMPLATE = """\
268No new revisions were added by this update.
269"""
270
271
272DISCARDED_REVISIONS_TEMPLATE = """\
273This change permanently discards the following revisions:
274"""
275
276
277NO_DISCARDED_REVISIONS_TEMPLATE = """\
278The revisions that were on this %(refname_type)s are still contained in
279other references; therefore, this change does not discard any commits
280from the repository.
281"""
282
283
284NEW_REVISIONS_TEMPLATE = """\
285The %(tot)s revisions listed above as "new" are entirely new to this
286repository and will be described in separate emails. The revisions
7c554311 287listed as "add" were already present in the repository and have only
bc501f69
MH
288been added to this reference.
289
290"""
291
292
293TAG_CREATED_TEMPLATE = """\
7c554311 294 at %(newrev_short)-8s (%(newrev_type)s)
bc501f69
MH
295"""
296
297
298TAG_UPDATED_TEMPLATE = """\
299*** WARNING: tag %(short_refname)s was modified! ***
300
7c554311
MM
301 from %(oldrev_short)-8s (%(oldrev_type)s)
302 to %(newrev_short)-8s (%(newrev_type)s)
bc501f69
MH
303"""
304
305
306TAG_DELETED_TEMPLATE = """\
307*** WARNING: tag %(short_refname)s was deleted! ***
308
309"""
310
311
312# The template used in summary tables. It looks best if this uses the
313# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
314BRIEF_SUMMARY_TEMPLATE = """\
7c554311 315%(action)8s %(rev_short)-8s %(text)s
bc501f69
MH
316"""
317
318
319NON_COMMIT_UPDATE_TEMPLATE = """\
320This is an unusual reference change because the reference did not
321refer to a commit either before or after the change. We do not know
322how to provide full information about this reference change.
323"""
324
325
326REVISION_HEADER_TEMPLATE = """\
b513f71f 327Date: %(send_date)s
bc501f69 328To: %(recipients)s
5b1d901c 329Cc: %(cc_recipients)s
bc501f69
MH
330Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
331MIME-Version: 1.0
4b1fd356 332Content-Type: text/%(contenttype)s; charset=%(charset)s
bc501f69
MH
333Content-Transfer-Encoding: 8bit
334From: %(fromaddr)s
335Reply-To: %(reply_to)s
336In-Reply-To: %(reply_to_msgid)s
337References: %(reply_to_msgid)s
99177b34 338Thread-Index: %(thread_index)s
b513f71f 339X-Git-Host: %(fqdn)s
bc501f69
MH
340X-Git-Repo: %(repo_shortname)s
341X-Git-Refname: %(refname)s
342X-Git-Reftype: %(refname_type)s
343X-Git-Rev: %(rev)s
4b1fd356
MM
344X-Git-NotificationType: diff
345X-Git-Multimail-Version: %(multimail_version)s
bc501f69
MH
346Auto-Submitted: auto-generated
347"""
348
349REVISION_INTRO_TEMPLATE = """\
350This is an automated email from the git hooks/post-receive script.
351
352%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
353in repository %(repo_shortname)s.
354
355"""
356
4453d76c
MM
357LINK_TEXT_TEMPLATE = """\
358View the commit online:
359%(browse_url)s
360
361"""
362
363LINK_HTML_TEMPLATE = """\
364<p><a href="%(browse_url)s">View the commit online</a>.</p>
365"""
366
bc501f69
MH
367
368REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
369
370
5b1d901c
MM
371# Combined, meaning refchange+revision email (for single-commit additions)
372COMBINED_HEADER_TEMPLATE = """\
373Date: %(send_date)s
374To: %(recipients)s
375Subject: %(subject)s
376MIME-Version: 1.0
4b1fd356 377Content-Type: text/%(contenttype)s; charset=%(charset)s
5b1d901c
MM
378Content-Transfer-Encoding: 8bit
379Message-ID: %(msgid)s
380From: %(fromaddr)s
381Reply-To: %(reply_to)s
382X-Git-Host: %(fqdn)s
383X-Git-Repo: %(repo_shortname)s
384X-Git-Refname: %(refname)s
385X-Git-Reftype: %(refname_type)s
386X-Git-Oldrev: %(oldrev)s
387X-Git-Newrev: %(newrev)s
388X-Git-Rev: %(rev)s
4b1fd356
MM
389X-Git-NotificationType: ref_changed_plus_diff
390X-Git-Multimail-Version: %(multimail_version)s
5b1d901c
MM
391Auto-Submitted: auto-generated
392"""
393
394COMBINED_INTRO_TEMPLATE = """\
395This is an automated email from the git hooks/post-receive script.
396
397%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
398in repository %(repo_shortname)s.
399
400"""
401
402COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
403
404
bc501f69
MH
405class CommandError(Exception):
406 def __init__(self, cmd, retcode):
407 self.cmd = cmd
408 self.retcode = retcode
409 Exception.__init__(
410 self,
411 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
412 )
413
414
415class ConfigurationException(Exception):
416 pass
417
418
b513f71f
MH
419# The "git" program (this could be changed to include a full path):
420GIT_EXECUTABLE = 'git'
421
422
423# How "git" should be invoked (including global arguments), as a list
424# of words. This variable is usually initialized automatically by
425# read_git_output() via choose_git_command(), but if a value is set
426# here then it will be used unconditionally.
427GIT_CMD = None
428
429
430def choose_git_command():
431 """Decide how to invoke git, and record the choice in GIT_CMD."""
432
433 global GIT_CMD
434
435 if GIT_CMD is None:
436 try:
437 # Check to see whether the "-c" option is accepted (it was
438 # only added in Git 1.7.2). We don't actually use the
439 # output of "git --version", though if we needed more
440 # specific version information this would be the place to
441 # do it.
442 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
443 read_output(cmd)
444 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
445 except CommandError:
446 GIT_CMD = [GIT_EXECUTABLE]
447
448
bc501f69
MH
449def read_git_output(args, input=None, keepends=False, **kw):
450 """Read the output of a Git command."""
451
b513f71f
MH
452 if GIT_CMD is None:
453 choose_git_command()
454
455 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
bc501f69
MH
456
457
458def read_output(cmd, input=None, keepends=False, **kw):
459 if input:
460 stdin = subprocess.PIPE
4b1fd356 461 input = str_to_bytes(input)
bc501f69
MH
462 else:
463 stdin = None
7c554311
MM
464 errors = 'strict'
465 if 'errors' in kw:
466 errors = kw['errors']
467 del kw['errors']
bc501f69 468 p = subprocess.Popen(
7c554311
MM
469 tuple(str_to_bytes(w) for w in cmd),
470 stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
bc501f69
MH
471 )
472 (out, err) = p.communicate(input)
7c554311 473 out = bytes_to_str(out, errors=errors)
bc501f69
MH
474 retcode = p.wait()
475 if retcode:
476 raise CommandError(cmd, retcode)
477 if not keepends:
478 out = out.rstrip('\n\r')
479 return out
480
481
482def read_git_lines(args, keepends=False, **kw):
483 """Return the lines output by Git command.
484
485 Return as single lines, with newlines stripped off."""
486
487 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
488
489
5b1d901c
MM
490def git_rev_list_ish(cmd, spec, args=None, **kw):
491 """Common functionality for invoking a 'git rev-list'-like command.
492
493 Parameters:
494 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
495 * spec is a list of revision arguments to pass to the named
496 command. If None, this function returns an empty list.
497 * args is a list of extra arguments passed to the named command.
498 * All other keyword arguments (if any) are passed to the
499 underlying read_git_lines() function.
500
501 Return the output of the Git command in the form of a list, one
502 entry per output line.
503 """
504 if spec is None:
505 return []
506 if args is None:
507 args = []
508 args = [cmd, '--stdin'] + args
509 spec_stdin = ''.join(s + '\n' for s in spec)
510 return read_git_lines(args, input=spec_stdin, **kw)
511
512
513def git_rev_list(spec, **kw):
514 """Run 'git rev-list' with the given list of revision arguments.
515
516 See git_rev_list_ish() for parameter and return value
517 documentation.
518 """
519 return git_rev_list_ish('rev-list', spec, **kw)
520
521
522def git_log(spec, **kw):
523 """Run 'git log' with the given list of revision arguments.
524
525 See git_rev_list_ish() for parameter and return value
526 documentation.
527 """
528 return git_rev_list_ish('log', spec, **kw)
529
530
b513f71f
MH
531def header_encode(text, header_name=None):
532 """Encode and line-wrap the value of an email header field."""
533
4b1fd356
MM
534 # Convert to unicode, if required.
535 if not isinstance(text, unicode):
536 text = unicode(text, 'utf-8')
537
538 if is_ascii(text):
539 charset = 'ascii'
540 else:
541 charset = 'utf-8'
542
543 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
b513f71f
MH
544
545
546def addr_header_encode(text, header_name=None):
547 """Encode and line-wrap the value of an email header field containing
548 email addresses."""
549
4b1fd356
MM
550 # Convert to unicode, if required.
551 if not isinstance(text, unicode):
552 text = unicode(text, 'utf-8')
553
554 text = ', '.join(
555 formataddr((header_encode(name), emailaddr))
556 for name, emailaddr in getaddresses([text])
557 )
558
559 if is_ascii(text):
560 charset = 'ascii'
561 else:
562 charset = 'utf-8'
563
564 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
b513f71f
MH
565
566
bc501f69
MH
567class Config(object):
568 def __init__(self, section, git_config=None):
569 """Represent a section of the git configuration.
570
571 If git_config is specified, it is passed to "git config" in
572 the GIT_CONFIG environment variable, meaning that "git config"
573 will read the specified path rather than the Git default
574 config paths."""
575
576 self.section = section
577 if git_config:
578 self.env = os.environ.copy()
579 self.env['GIT_CONFIG'] = git_config
580 else:
581 self.env = None
582
583 @staticmethod
584 def _split(s):
585 """Split NUL-terminated values."""
586
587 words = s.split('\0')
588 assert words[-1] == ''
589 return words[:-1]
590
4453d76c
MM
591 @staticmethod
592 def add_config_parameters(c):
593 """Add configuration parameters to Git.
594
595 c is either an str or a list of str, each element being of the
596 form 'var=val' or 'var', with the same syntax and meaning as
597 the argument of 'git -c var=val'.
598 """
599 if isinstance(c, str):
600 c = (c,)
601 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
602 if parameters:
603 parameters += ' '
604 # git expects GIT_CONFIG_PARAMETERS to be of the form
605 # "'name1=value1' 'name2=value2' 'name3=value3'"
606 # including everything inside the double quotes (but not the double
607 # quotes themselves). Spacing is critical. Also, if a value contains
608 # a literal single quote that quote must be represented using the
609 # four character sequence: '\''
610 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
611 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
612
bc501f69
MH
613 def get(self, name, default=None):
614 try:
615 values = self._split(read_git_output(
5b1d901c
MM
616 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
617 env=self.env, keepends=True,
618 ))
bc501f69
MH
619 assert len(values) == 1
620 return values[0]
621 except CommandError:
622 return default
623
624 def get_bool(self, name, default=None):
625 try:
626 value = read_git_output(
627 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
628 env=self.env,
629 )
630 except CommandError:
631 return default
632 return value == 'true'
633
634 def get_all(self, name, default=None):
635 """Read a (possibly multivalued) setting from the configuration.
636
637 Return the result as a list of values, or default if the name
638 is unset."""
639
640 try:
641 return self._split(read_git_output(
642 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
643 env=self.env, keepends=True,
644 ))
4b1fd356
MM
645 except CommandError:
646 t, e, traceback = sys.exc_info()
bc501f69
MH
647 if e.retcode == 1:
648 # "the section or key is invalid"; i.e., there is no
649 # value for the specified key.
650 return default
651 else:
652 raise
653
bc501f69
MH
654 def set(self, name, value):
655 read_git_output(
656 ['config', '%s.%s' % (self.section, name), value],
657 env=self.env,
658 )
659
660 def add(self, name, value):
661 read_git_output(
662 ['config', '--add', '%s.%s' % (self.section, name), value],
663 env=self.env,
664 )
665
5b1d901c 666 def __contains__(self, name):
bc501f69
MH
667 return self.get_all(name, default=None) is not None
668
5b1d901c
MM
669 # We don't use this method anymore internally, but keep it here in
670 # case somebody is calling it from their own code:
671 def has_key(self, name):
672 return name in self
673
bc501f69
MH
674 def unset_all(self, name):
675 try:
676 read_git_output(
677 ['config', '--unset-all', '%s.%s' % (self.section, name)],
678 env=self.env,
679 )
4b1fd356
MM
680 except CommandError:
681 t, e, traceback = sys.exc_info()
bc501f69
MH
682 if e.retcode == 5:
683 # The name doesn't exist, which is what we wanted anyway...
684 pass
685 else:
686 raise
687
688 def set_recipients(self, name, value):
689 self.unset_all(name)
690 for pair in getaddresses([value]):
691 self.add(name, formataddr(pair))
692
693
694def generate_summaries(*log_args):
695 """Generate a brief summary for each revision requested.
696
697 log_args are strings that will be passed directly to "git log" as
698 revision selectors. Iterate over (sha1_short, subject) for each
699 commit specified by log_args (subject is the first line of the
700 commit message as a string without EOLs)."""
701
702 cmd = [
703 'log', '--abbrev', '--format=%h %s',
704 ] + list(log_args) + ['--']
705 for line in read_git_lines(cmd):
706 yield tuple(line.split(' ', 1))
707
708
709def limit_lines(lines, max_lines):
710 for (index, line) in enumerate(lines):
711 if index < max_lines:
712 yield line
713
714 if index >= max_lines:
715 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
716
717
718def limit_linelength(lines, max_linelength):
719 for line in lines:
720 # Don't forget that lines always include a trailing newline.
721 if len(line) > max_linelength + 1:
722 line = line[:max_linelength - 7] + ' [...]\n'
723 yield line
724
725
726class CommitSet(object):
727 """A (constant) set of object names.
728
729 The set should be initialized with full SHA1 object names. The
730 __contains__() method returns True iff its argument is an
731 abbreviation of any the names in the set."""
732
733 def __init__(self, names):
734 self._names = sorted(names)
735
736 def __len__(self):
737 return len(self._names)
738
739 def __contains__(self, sha1_abbrev):
740 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
741
742 i = bisect.bisect_left(self._names, sha1_abbrev)
743 return i < len(self) and self._names[i].startswith(sha1_abbrev)
744
745
746class GitObject(object):
747 def __init__(self, sha1, type=None):
748 if sha1 == ZEROS:
749 self.sha1 = self.type = self.commit_sha1 = None
750 else:
751 self.sha1 = sha1
752 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
753
754 if self.type == 'commit':
755 self.commit_sha1 = self.sha1
756 elif self.type == 'tag':
757 try:
758 self.commit_sha1 = read_git_output(
759 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
760 )
761 except CommandError:
762 # Cannot deref tag to determine commit_sha1
763 self.commit_sha1 = None
764 else:
765 self.commit_sha1 = None
766
767 self.short = read_git_output(['rev-parse', '--short', sha1])
768
769 def get_summary(self):
770 """Return (sha1_short, subject) for this commit."""
771
772 if not self.sha1:
773 raise ValueError('Empty commit has no summary')
774
4b1fd356 775 return next(iter(generate_summaries('--no-walk', self.sha1)))
bc501f69
MH
776
777 def __eq__(self, other):
778 return isinstance(other, GitObject) and self.sha1 == other.sha1
779
99177b34
MM
780 def __ne__(self, other):
781 return not self == other
782
bc501f69
MH
783 def __hash__(self):
784 return hash(self.sha1)
785
786 def __nonzero__(self):
787 return bool(self.sha1)
788
4b1fd356
MM
789 def __bool__(self):
790 """Python 2 backward compatibility"""
791 return self.__nonzero__()
792
bc501f69
MH
793 def __str__(self):
794 return self.sha1 or ZEROS
795
796
797class Change(object):
798 """A Change that has been made to the Git repository.
799
800 Abstract class from which both Revisions and ReferenceChanges are
801 derived. A Change knows how to generate a notification email
802 describing itself."""
803
804 def __init__(self, environment):
805 self.environment = environment
806 self._values = None
4b1fd356
MM
807 self._contains_html_diff = False
808
809 def _contains_diff(self):
810 # We do contain a diff, should it be rendered in HTML?
811 if self.environment.commit_email_format == "html":
812 self._contains_html_diff = True
bc501f69
MH
813
814 def _compute_values(self):
5b1d901c 815 """Return a dictionary {keyword: expansion} for this Change.
bc501f69
MH
816
817 Derived classes overload this method to add more entries to
818 the return value. This method is used internally by
819 get_values(). The return value should always be a new
820 dictionary."""
821
4b1fd356
MM
822 values = self.environment.get_values()
823 fromaddr = self.environment.get_fromaddr(change=self)
824 if fromaddr is not None:
825 values['fromaddr'] = fromaddr
826 values['multimail_version'] = get_version()
827 return values
bc501f69 828
4453d76c
MM
829 # Aliases usable in template strings. Tuple of pairs (destination,
830 # source).
831 VALUES_ALIAS = (
832 ("id", "newrev"),
833 )
834
bc501f69 835 def get_values(self, **extra_values):
5b1d901c 836 """Return a dictionary {keyword: expansion} for this Change.
bc501f69
MH
837
838 Return a dictionary mapping keywords to the values that they
839 should be expanded to for this Change (used when interpolating
840 template strings). If any keyword arguments are supplied, add
841 those to the return value as well. The return value is always
842 a new dictionary."""
843
844 if self._values is None:
845 self._values = self._compute_values()
846
847 values = self._values.copy()
848 if extra_values:
849 values.update(extra_values)
4453d76c
MM
850
851 for alias, val in self.VALUES_ALIAS:
852 values[alias] = values[val]
bc501f69
MH
853 return values
854
855 def expand(self, template, **extra_values):
856 """Expand template.
857
858 Expand the template (which should be a string) using string
859 interpolation of the values for this Change. If any keyword
860 arguments are provided, also include those in the keywords
861 available for interpolation."""
862
863 return template % self.get_values(**extra_values)
864
4453d76c 865 def expand_lines(self, template, html_escape_val=False, **extra_values):
bc501f69
MH
866 """Break template into lines and expand each line."""
867
868 values = self.get_values(**extra_values)
4453d76c
MM
869 if html_escape_val:
870 for k in values:
871 if is_string(values[k]):
99177b34 872 values[k] = html_escape(values[k])
bc501f69
MH
873 for line in template.splitlines(True):
874 yield line % values
875
876 def expand_header_lines(self, template, **extra_values):
877 """Break template into lines and expand each line as an RFC 2822 header.
878
879 Encode values and split up lines that are too long. Silently
880 skip lines that contain references to unknown variables."""
881
882 values = self.get_values(**extra_values)
4b1fd356 883 if self._contains_html_diff:
4453d76c 884 self._content_type = 'html'
4b1fd356 885 else:
4453d76c
MM
886 self._content_type = 'plain'
887 values['contenttype'] = self._content_type
4b1fd356 888
bc501f69 889 for line in template.splitlines():
4b1fd356 890 (name, value) = line.split(': ', 1)
bc501f69
MH
891
892 try:
893 value = value % values
4b1fd356
MM
894 except KeyError:
895 t, e, traceback = sys.exc_info()
bc501f69 896 if DEBUG:
5b1d901c 897 self.environment.log_warning(
bc501f69
MH
898 'Warning: unknown variable %r in the following line; line skipped:\n'
899 ' %s\n'
900 % (e.args[0], line,)
901 )
902 else:
b513f71f
MH
903 if name.lower() in ADDR_HEADERS:
904 value = addr_header_encode(value, name)
905 else:
906 value = header_encode(value, name)
907 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
bc501f69
MH
908 yield splitline
909
910 def generate_email_header(self):
911 """Generate the RFC 2822 email headers for this Change, a line at a time.
912
913 The output should not include the trailing blank line."""
914
915 raise NotImplementedError()
916
4453d76c
MM
917 def generate_browse_link(self, base_url):
918 """Generate a link to an online repository browser."""
919 return iter(())
920
921 def generate_email_intro(self, html_escape_val=False):
bc501f69
MH
922 """Generate the email intro for this Change, a line at a time.
923
924 The output will be used as the standard boilerplate at the top
925 of the email body."""
926
927 raise NotImplementedError()
928
99177b34 929 def generate_email_body(self, push):
bc501f69
MH
930 """Generate the main part of the email body, a line at a time.
931
932 The text in the body might be truncated after a specified
933 number of lines (see multimailhook.emailmaxlines)."""
934
935 raise NotImplementedError()
936
4453d76c 937 def generate_email_footer(self, html_escape_val):
bc501f69
MH
938 """Generate the footer of the email, a line at a time.
939
940 The footer is always included, irrespective of
941 multimailhook.emailmaxlines."""
942
943 raise NotImplementedError()
944
4b1fd356
MM
945 def _wrap_for_html(self, lines):
946 """Wrap the lines in HTML <pre> tag when using HTML format.
947
948 Escape special HTML characters and add <pre> and </pre> tags around
949 the given lines if we should be generating HTML as indicated by
950 self._contains_html_diff being set to true.
951 """
952 if self._contains_html_diff:
953 yield "<pre style='margin:0'>\n"
954
955 for line in lines:
99177b34 956 yield html_escape(line)
4b1fd356
MM
957
958 yield '</pre>\n'
959 else:
960 for line in lines:
961 yield line
962
b513f71f 963 def generate_email(self, push, body_filter=None, extra_header_values={}):
bc501f69
MH
964 """Generate an email describing this change.
965
966 Iterate over the lines (including the header lines) of an
967 email describing this change. If body_filter is not None,
968 then use it to filter the lines that are intended for the
b513f71f
MH
969 email body.
970
971 The extra_header_values field is received as a dict and not as
972 **kwargs, to allow passing other keyword arguments in the
973 future (e.g. passing extra values to generate_email_intro()"""
bc501f69 974
b513f71f 975 for line in self.generate_email_header(**extra_header_values):
bc501f69
MH
976 yield line
977 yield '\n'
4453d76c
MM
978 html_escape_val = (self.environment.html_in_intro and
979 self._contains_html_diff)
980 intro = self.generate_email_intro(html_escape_val)
981 if not self.environment.html_in_intro:
982 intro = self._wrap_for_html(intro)
983 for line in intro:
bc501f69
MH
984 yield line
985
4453d76c
MM
986 if self.environment.commitBrowseURL:
987 for line in self.generate_browse_link(self.environment.commitBrowseURL):
988 yield line
989
bc501f69
MH
990 body = self.generate_email_body(push)
991 if body_filter is not None:
992 body = body_filter(body)
4b1fd356
MM
993
994 diff_started = False
995 if self._contains_html_diff:
996 # "white-space: pre" is the default, but we need to
997 # specify it again in case the message is viewed in a
998 # webmail which wraps it in an element setting white-space
999 # to something else (Zimbra does this and sets
1000 # white-space: pre-line).
1001 yield '<pre style="white-space: pre; background: #F8F8F8">'
bc501f69 1002 for line in body:
4b1fd356
MM
1003 if self._contains_html_diff:
1004 # This is very, very naive. It would be much better to really
1005 # parse the diff, i.e. look at how many lines do we have in
1006 # the hunk headers instead of blindly highlighting everything
1007 # that looks like it might be part of a diff.
1008 bgcolor = ''
1009 fgcolor = ''
1010 if line.startswith('--- a/'):
1011 diff_started = True
1012 bgcolor = 'e0e0ff'
1013 elif line.startswith('diff ') or line.startswith('index '):
1014 diff_started = True
1015 fgcolor = '808080'
1016 elif diff_started:
1017 if line.startswith('+++ '):
1018 bgcolor = 'e0e0ff'
1019 elif line.startswith('@@'):
1020 bgcolor = 'e0e0e0'
1021 elif line.startswith('+'):
1022 bgcolor = 'e0ffe0'
1023 elif line.startswith('-'):
1024 bgcolor = 'ffe0e0'
1025 elif line.startswith('commit '):
1026 fgcolor = '808000'
1027 elif line.startswith(' '):
1028 fgcolor = '404040'
1029
1030 # Chop the trailing LF, we don't want it inside <pre>.
99177b34 1031 line = html_escape(line[:-1])
4b1fd356
MM
1032
1033 if bgcolor or fgcolor:
1034 style = 'display:block; white-space:pre;'
1035 if bgcolor:
1036 style += 'background:#' + bgcolor + ';'
1037 if fgcolor:
1038 style += 'color:#' + fgcolor + ';'
1039 # Use a <span style='display:block> to color the
1040 # whole line. The newline must be inside the span
1041 # to display properly both in Firefox and in
1042 # text-based browser.
1043 line = "<span style='%s'>%s\n</span>" % (style, line)
1044 else:
1045 line = line + '\n'
1046
bc501f69 1047 yield line
4b1fd356
MM
1048 if self._contains_html_diff:
1049 yield '</pre>'
4453d76c
MM
1050 html_escape_val = (self.environment.html_in_footer and
1051 self._contains_html_diff)
1052 footer = self.generate_email_footer(html_escape_val)
1053 if not self.environment.html_in_footer:
1054 footer = self._wrap_for_html(footer)
1055 for line in footer:
bc501f69
MH
1056 yield line
1057
7c554311
MM
1058 def get_specific_fromaddr(self):
1059 """For kinds of Changes which specify it, return the kind-specific
1060 From address to use."""
4b1fd356
MM
1061 return None
1062
bc501f69
MH
1063
1064class Revision(Change):
1065 """A Change consisting of a single git commit."""
1066
5b1d901c
MM
1067 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1068
bc501f69
MH
1069 def __init__(self, reference_change, rev, num, tot):
1070 Change.__init__(self, reference_change.environment)
1071 self.reference_change = reference_change
1072 self.rev = rev
1073 self.change_type = self.reference_change.change_type
1074 self.refname = self.reference_change.refname
1075 self.num = num
1076 self.tot = tot
1077 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1078 self.recipients = self.environment.get_revision_recipients(self)
1079
99177b34
MM
1080 # -s is short for --no-patch, but -s works on older git's (e.g. 1.7)
1081 self.parents = read_git_lines(['show', '-s', '--format=%P',
1082 self.rev.sha1])[0].split()
1083
5b1d901c
MM
1084 self.cc_recipients = ''
1085 if self.environment.get_scancommitforcc():
1086 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1087 if self.cc_recipients:
1088 self.environment.log_msg(
7c554311 1089 'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1))
5b1d901c
MM
1090
1091 def _cc_recipients(self):
1092 cc_recipients = []
1093 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1094 lines = message.strip().split('\n')
1095 for line in lines:
1096 m = re.match(self.CC_RE, line)
1097 if m:
1098 cc_recipients.append(m.group('to'))
1099
1100 return cc_recipients
1101
bc501f69
MH
1102 def _compute_values(self):
1103 values = Change._compute_values(self)
1104
1105 oneline = read_git_output(
1106 ['log', '--format=%s', '--no-walk', self.rev.sha1]
1107 )
1108
7c554311
MM
1109 max_subject_length = self.environment.get_max_subject_length()
1110 if max_subject_length > 0 and len(oneline) > max_subject_length:
1111 oneline = oneline[:max_subject_length - 6] + ' [...]'
1112
bc501f69 1113 values['rev'] = self.rev.sha1
99177b34 1114 values['parents'] = ' '.join(self.parents)
bc501f69
MH
1115 values['rev_short'] = self.rev.short
1116 values['change_type'] = self.change_type
1117 values['refname'] = self.refname
4453d76c 1118 values['newrev'] = self.rev.sha1
bc501f69
MH
1119 values['short_refname'] = self.reference_change.short_refname
1120 values['refname_type'] = self.reference_change.refname_type
1121 values['reply_to_msgid'] = self.reference_change.msgid
99177b34 1122 values['thread_index'] = self.reference_change.thread_index
bc501f69
MH
1123 values['num'] = self.num
1124 values['tot'] = self.tot
1125 values['recipients'] = self.recipients
5b1d901c
MM
1126 if self.cc_recipients:
1127 values['cc_recipients'] = self.cc_recipients
bc501f69
MH
1128 values['oneline'] = oneline
1129 values['author'] = self.author
1130
1131 reply_to = self.environment.get_reply_to_commit(self)
1132 if reply_to:
1133 values['reply_to'] = reply_to
1134
1135 return values
1136
b513f71f
MH
1137 def generate_email_header(self, **extra_values):
1138 for line in self.expand_header_lines(
5b1d901c
MM
1139 REVISION_HEADER_TEMPLATE, **extra_values
1140 ):
bc501f69
MH
1141 yield line
1142
4453d76c
MM
1143 def generate_browse_link(self, base_url):
1144 if '%(' not in base_url:
1145 base_url += '%(id)s'
1146 url = "".join(self.expand_lines(base_url))
1147 if self._content_type == 'html':
1148 for line in self.expand_lines(LINK_HTML_TEMPLATE,
1149 html_escape_val=True,
1150 browse_url=url):
1151 yield line
1152 elif self._content_type == 'plain':
1153 for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1154 html_escape_val=False,
1155 browse_url=url):
1156 yield line
1157 else:
1158 raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1159
1160 def generate_email_intro(self, html_escape_val=False):
1161 for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1162 html_escape_val=html_escape_val):
bc501f69
MH
1163 yield line
1164
1165 def generate_email_body(self, push):
1166 """Show this revision."""
1167
4b1fd356
MM
1168 for line in read_git_lines(
1169 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1170 keepends=True,
7c554311 1171 errors='replace'):
4b1fd356
MM
1172 if line.startswith('Date: ') and self.environment.date_substitute:
1173 yield self.environment.date_substitute + line[len('Date: '):]
1174 else:
1175 yield line
bc501f69 1176
4453d76c
MM
1177 def generate_email_footer(self, html_escape_val):
1178 return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1179 html_escape_val=html_escape_val)
bc501f69 1180
4b1fd356
MM
1181 def generate_email(self, push, body_filter=None, extra_header_values={}):
1182 self._contains_diff()
1183 return Change.generate_email(self, push, body_filter, extra_header_values)
1184
7c554311 1185 def get_specific_fromaddr(self):
4b1fd356
MM
1186 return self.environment.from_commit
1187
bc501f69
MH
1188
1189class ReferenceChange(Change):
1190 """A Change to a Git reference.
1191
1192 An abstract class representing a create, update, or delete of a
1193 Git reference. Derived classes handle specific types of reference
1194 (e.g., tags vs. branches). These classes generate the main
1195 reference change email summarizing the reference change and
1196 whether it caused any any commits to be added or removed.
1197
1198 ReferenceChange objects are usually created using the static
1199 create() method, which has the logic to decide which derived class
1200 to instantiate."""
1201
1202 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1203
1204 @staticmethod
1205 def create(environment, oldrev, newrev, refname):
1206 """Return a ReferenceChange object representing the change.
1207
1208 Return an object that represents the type of change that is being
1209 made. oldrev and newrev should be SHA1s or ZEROS."""
1210
1211 old = GitObject(oldrev)
1212 new = GitObject(newrev)
1213 rev = new or old
1214
1215 # The revision type tells us what type the commit is, combined with
1216 # the location of the ref we can decide between
1217 # - working branch
1218 # - tracking branch
1219 # - unannotated tag
1220 # - annotated tag
1221 m = ReferenceChange.REF_RE.match(refname)
1222 if m:
1223 area = m.group('area')
1224 short_refname = m.group('shortname')
1225 else:
1226 area = ''
1227 short_refname = refname
1228
1229 if rev.type == 'tag':
1230 # Annotated tag:
1231 klass = AnnotatedTagChange
1232 elif rev.type == 'commit':
1233 if area == 'tags':
1234 # Non-annotated tag:
1235 klass = NonAnnotatedTagChange
1236 elif area == 'heads':
1237 # Branch:
1238 klass = BranchChange
1239 elif area == 'remotes':
1240 # Tracking branch:
5b1d901c 1241 environment.log_warning(
bc501f69 1242 '*** Push-update of tracking branch %r\n'
7c554311 1243 '*** - incomplete email generated.'
5b1d901c 1244 % (refname,)
bc501f69
MH
1245 )
1246 klass = OtherReferenceChange
1247 else:
1248 # Some other reference namespace:
5b1d901c 1249 environment.log_warning(
bc501f69 1250 '*** Push-update of strange reference %r\n'
7c554311 1251 '*** - incomplete email generated.'
5b1d901c 1252 % (refname,)
bc501f69
MH
1253 )
1254 klass = OtherReferenceChange
1255 else:
1256 # Anything else (is there anything else?)
5b1d901c 1257 environment.log_warning(
bc501f69 1258 '*** Unknown type of update to %r (%s)\n'
7c554311 1259 '*** - incomplete email generated.'
5b1d901c 1260 % (refname, rev.type,)
bc501f69
MH
1261 )
1262 klass = OtherReferenceChange
1263
1264 return klass(
1265 environment,
1266 refname=refname, short_refname=short_refname,
1267 old=old, new=new, rev=rev,
1268 )
1269
99177b34
MM
1270 @staticmethod
1271 def make_thread_index():
1272 """Return a string appropriate for the Thread-Index header,
1273 needed by MS Outlook to get threading right.
1274
1275 The format is (base64-encoded):
1276 - 1 byte must be 1
1277 - 5 bytes encode a date (hardcoded here)
1278 - 16 bytes for a globally unique identifier
1279
1280 FIXME: Unfortunately, even with the Thread-Index field, MS
1281 Outlook doesn't seem to do the threading reliably (see
1282 https://github.com/git-multimail/git-multimail/pull/194).
1283 """
1284 thread_index = b'\x01\x00\x00\x12\x34\x56' + uuid.uuid4().bytes
1285 return base64.standard_b64encode(thread_index).decode('ascii')
1286
bc501f69
MH
1287 def __init__(self, environment, refname, short_refname, old, new, rev):
1288 Change.__init__(self, environment)
1289 self.change_type = {
5b1d901c
MM
1290 (False, True): 'create',
1291 (True, True): 'update',
1292 (True, False): 'delete',
bc501f69
MH
1293 }[bool(old), bool(new)]
1294 self.refname = refname
1295 self.short_refname = short_refname
1296 self.old = old
1297 self.new = new
1298 self.rev = rev
1299 self.msgid = make_msgid()
99177b34 1300 self.thread_index = self.make_thread_index()
bc501f69 1301 self.diffopts = environment.diffopts
5b1d901c 1302 self.graphopts = environment.graphopts
bc501f69 1303 self.logopts = environment.logopts
b513f71f 1304 self.commitlogopts = environment.commitlogopts
5b1d901c 1305 self.showgraph = environment.refchange_showgraph
bc501f69
MH
1306 self.showlog = environment.refchange_showlog
1307
5b1d901c
MM
1308 self.header_template = REFCHANGE_HEADER_TEMPLATE
1309 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1310 self.footer_template = FOOTER_TEMPLATE
1311
bc501f69
MH
1312 def _compute_values(self):
1313 values = Change._compute_values(self)
1314
1315 values['change_type'] = self.change_type
1316 values['refname_type'] = self.refname_type
1317 values['refname'] = self.refname
1318 values['short_refname'] = self.short_refname
1319 values['msgid'] = self.msgid
99177b34 1320 values['thread_index'] = self.thread_index
bc501f69
MH
1321 values['recipients'] = self.recipients
1322 values['oldrev'] = str(self.old)
1323 values['oldrev_short'] = self.old.short
1324 values['newrev'] = str(self.new)
1325 values['newrev_short'] = self.new.short
1326
1327 if self.old:
1328 values['oldrev_type'] = self.old.type
1329 if self.new:
1330 values['newrev_type'] = self.new.type
1331
1332 reply_to = self.environment.get_reply_to_refchange(self)
1333 if reply_to:
1334 values['reply_to'] = reply_to
1335
1336 return values
1337
5b1d901c
MM
1338 def send_single_combined_email(self, known_added_sha1s):
1339 """Determine if a combined refchange/revision email should be sent
1340
1341 If there is only a single new (non-merge) commit added by a
1342 change, it is useful to combine the ReferenceChange and
1343 Revision emails into one. In such a case, return the single
1344 revision; otherwise, return None.
1345
1346 This method is overridden in BranchChange."""
1347
1348 return None
1349
1350 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1351 """Generate an email describing this change AND specified revision.
1352
1353 Iterate over the lines (including the header lines) of an
1354 email describing this change. If body_filter is not None,
1355 then use it to filter the lines that are intended for the
1356 email body.
1357
1358 The extra_header_values field is received as a dict and not as
1359 **kwargs, to allow passing other keyword arguments in the
1360 future (e.g. passing extra values to generate_email_intro()
1361
1362 This method is overridden in BranchChange."""
1363
1364 raise NotImplementedError
1365
bc501f69
MH
1366 def get_subject(self):
1367 template = {
5b1d901c
MM
1368 'create': REF_CREATED_SUBJECT_TEMPLATE,
1369 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1370 'delete': REF_DELETED_SUBJECT_TEMPLATE,
bc501f69
MH
1371 }[self.change_type]
1372 return self.expand(template)
1373
b513f71f
MH
1374 def generate_email_header(self, **extra_values):
1375 if 'subject' not in extra_values:
1376 extra_values['subject'] = self.get_subject()
1377
bc501f69 1378 for line in self.expand_header_lines(
5b1d901c
MM
1379 self.header_template, **extra_values
1380 ):
bc501f69
MH
1381 yield line
1382
4453d76c
MM
1383 def generate_email_intro(self, html_escape_val=False):
1384 for line in self.expand_lines(self.intro_template,
1385 html_escape_val=html_escape_val):
bc501f69
MH
1386 yield line
1387
1388 def generate_email_body(self, push):
1389 """Call the appropriate body-generation routine.
1390
1391 Call one of generate_create_summary() /
1392 generate_update_summary() / generate_delete_summary()."""
1393
1394 change_summary = {
5b1d901c
MM
1395 'create': self.generate_create_summary,
1396 'delete': self.generate_delete_summary,
1397 'update': self.generate_update_summary,
bc501f69
MH
1398 }[self.change_type](push)
1399 for line in change_summary:
1400 yield line
1401
1402 for line in self.generate_revision_change_summary(push):
1403 yield line
1404
4453d76c
MM
1405 def generate_email_footer(self, html_escape_val):
1406 return self.expand_lines(self.footer_template,
1407 html_escape_val=html_escape_val)
5b1d901c
MM
1408
1409 def generate_revision_change_graph(self, push):
1410 if self.showgraph:
1411 args = ['--graph'] + self.graphopts
1412 for newold in ('new', 'old'):
1413 has_newold = False
1414 spec = push.get_commits_spec(newold, self)
1415 for line in git_log(spec, args=args, keepends=True):
1416 if not has_newold:
1417 has_newold = True
1418 yield '\n'
1419 yield 'Graph of %s commits:\n\n' % (
1420 {'new': 'new', 'old': 'discarded'}[newold],)
1421 yield ' ' + line
1422 if has_newold:
1423 yield '\n'
bc501f69
MH
1424
1425 def generate_revision_change_log(self, new_commits_list):
1426 if self.showlog:
1427 yield '\n'
1428 yield 'Detailed log of new commits:\n\n'
1429 for line in read_git_lines(
4b1fd356
MM
1430 ['log', '--no-walk'] +
1431 self.logopts +
1432 new_commits_list +
1433 ['--'],
bc501f69 1434 keepends=True,
5b1d901c 1435 ):
bc501f69
MH
1436 yield line
1437
5b1d901c
MM
1438 def generate_new_revision_summary(self, tot, new_commits_list, push):
1439 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1440 yield line
1441 for line in self.generate_revision_change_graph(push):
1442 yield line
1443 for line in self.generate_revision_change_log(new_commits_list):
1444 yield line
1445
bc501f69
MH
1446 def generate_revision_change_summary(self, push):
1447 """Generate a summary of the revisions added/removed by this change."""
1448
1449 if self.new.commit_sha1 and not self.old.commit_sha1:
1450 # A new reference was created. List the new revisions
1451 # brought by the new reference (i.e., those revisions that
1452 # were not in the repository before this reference
1453 # change).
1454 sha1s = list(push.get_new_commits(self))
1455 sha1s.reverse()
1456 tot = len(sha1s)
1457 new_revisions = [
5b1d901c 1458 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
bc501f69
MH
1459 for (i, sha1) in enumerate(sha1s)
1460 ]
1461
1462 if new_revisions:
1463 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1464 yield '\n'
1465 for r in new_revisions:
1466 (sha1, subject) = r.rev.get_summary()
1467 yield r.expand(
1468 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1469 )
1470 yield '\n'
5b1d901c
MM
1471 for line in self.generate_new_revision_summary(
1472 tot, [r.rev.sha1 for r in new_revisions], push):
bc501f69
MH
1473 yield line
1474 else:
1475 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1476 yield line
1477
1478 elif self.new.commit_sha1 and self.old.commit_sha1:
1479 # A reference was changed to point at a different commit.
1480 # List the revisions that were removed and/or added *from
1481 # that reference* by this reference change, along with a
1482 # diff between the trees for its old and new values.
1483
1484 # List of the revisions that were added to the branch by
1485 # this update. Note this list can include revisions that
1486 # have already had notification emails; we want such
1487 # revisions in the summary even though we will not send
1488 # new notification emails for them.
1489 adds = list(generate_summaries(
5b1d901c
MM
1490 '--topo-order', '--reverse', '%s..%s'
1491 % (self.old.commit_sha1, self.new.commit_sha1,)
1492 ))
bc501f69
MH
1493
1494 # List of the revisions that were removed from the branch
1495 # by this update. This will be empty except for
1496 # non-fast-forward updates.
1497 discards = list(generate_summaries(
5b1d901c
MM
1498 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1499 ))
bc501f69
MH
1500
1501 if adds:
1502 new_commits_list = push.get_new_commits(self)
1503 else:
1504 new_commits_list = []
1505 new_commits = CommitSet(new_commits_list)
1506
1507 if discards:
1508 discarded_commits = CommitSet(push.get_discarded_commits(self))
1509 else:
1510 discarded_commits = CommitSet([])
1511
1512 if discards and adds:
1513 for (sha1, subject) in discards:
1514 if sha1 in discarded_commits:
7c554311 1515 action = 'discard'
bc501f69 1516 else:
7c554311 1517 action = 'omit'
bc501f69
MH
1518 yield self.expand(
1519 BRIEF_SUMMARY_TEMPLATE, action=action,
1520 rev_short=sha1, text=subject,
1521 )
1522 for (sha1, subject) in adds:
1523 if sha1 in new_commits:
1524 action = 'new'
1525 else:
7c554311 1526 action = 'add'
bc501f69
MH
1527 yield self.expand(
1528 BRIEF_SUMMARY_TEMPLATE, action=action,
1529 rev_short=sha1, text=subject,
1530 )
1531 yield '\n'
1532 for line in self.expand_lines(NON_FF_TEMPLATE):
1533 yield line
1534
1535 elif discards:
1536 for (sha1, subject) in discards:
1537 if sha1 in discarded_commits:
7c554311 1538 action = 'discard'
bc501f69 1539 else:
7c554311 1540 action = 'omit'
bc501f69
MH
1541 yield self.expand(
1542 BRIEF_SUMMARY_TEMPLATE, action=action,
1543 rev_short=sha1, text=subject,
1544 )
1545 yield '\n'
1546 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1547 yield line
1548
1549 elif adds:
1550 (sha1, subject) = self.old.get_summary()
1551 yield self.expand(
1552 BRIEF_SUMMARY_TEMPLATE, action='from',
1553 rev_short=sha1, text=subject,
1554 )
1555 for (sha1, subject) in adds:
1556 if sha1 in new_commits:
1557 action = 'new'
1558 else:
7c554311 1559 action = 'add'
bc501f69
MH
1560 yield self.expand(
1561 BRIEF_SUMMARY_TEMPLATE, action=action,
1562 rev_short=sha1, text=subject,
1563 )
1564
1565 yield '\n'
1566
1567 if new_commits:
5b1d901c
MM
1568 for line in self.generate_new_revision_summary(
1569 len(new_commits), new_commits_list, push):
bc501f69
MH
1570 yield line
1571 else:
1572 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1573 yield line
5b1d901c
MM
1574 for line in self.generate_revision_change_graph(push):
1575 yield line
bc501f69
MH
1576
1577 # The diffstat is shown from the old revision to the new
1578 # revision. This is to show the truth of what happened in
1579 # this change. There's no point showing the stat from the
1580 # base to the new revision because the base is effectively a
1581 # random revision at this point - the user will be interested
1582 # in what this revision changed - including the undoing of
1583 # previous revisions in the case of non-fast-forward updates.
1584 yield '\n'
1585 yield 'Summary of changes:\n'
1586 for line in read_git_lines(
4b1fd356
MM
1587 ['diff-tree'] +
1588 self.diffopts +
1589 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
5b1d901c
MM
1590 keepends=True,
1591 ):
bc501f69
MH
1592 yield line
1593
1594 elif self.old.commit_sha1 and not self.new.commit_sha1:
1595 # A reference was deleted. List the revisions that were
1596 # removed from the repository by this reference change.
1597
1598 sha1s = list(push.get_discarded_commits(self))
1599 tot = len(sha1s)
1600 discarded_revisions = [
5b1d901c 1601 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
bc501f69
MH
1602 for (i, sha1) in enumerate(sha1s)
1603 ]
1604
1605 if discarded_revisions:
1606 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1607 yield line
1608 yield '\n'
1609 for r in discarded_revisions:
1610 (sha1, subject) = r.rev.get_summary()
1611 yield r.expand(
7c554311 1612 BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,
bc501f69 1613 )
5b1d901c
MM
1614 for line in self.generate_revision_change_graph(push):
1615 yield line
bc501f69
MH
1616 else:
1617 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1618 yield line
1619
1620 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1621 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1622 yield line
1623
1624 def generate_create_summary(self, push):
1625 """Called for the creation of a reference."""
1626
1627 # This is a new reference and so oldrev is not valid
1628 (sha1, subject) = self.new.get_summary()
1629 yield self.expand(
1630 BRIEF_SUMMARY_TEMPLATE, action='at',
1631 rev_short=sha1, text=subject,
1632 )
1633 yield '\n'
1634
1635 def generate_update_summary(self, push):
1636 """Called for the change of a pre-existing branch."""
1637
1638 return iter([])
1639
1640 def generate_delete_summary(self, push):
1641 """Called for the deletion of any type of reference."""
1642
1643 (sha1, subject) = self.old.get_summary()
1644 yield self.expand(
1645 BRIEF_SUMMARY_TEMPLATE, action='was',
1646 rev_short=sha1, text=subject,
1647 )
1648 yield '\n'
1649
7c554311 1650 def get_specific_fromaddr(self):
4b1fd356
MM
1651 return self.environment.from_refchange
1652
bc501f69
MH
1653
1654class BranchChange(ReferenceChange):
1655 refname_type = 'branch'
1656
1657 def __init__(self, environment, refname, short_refname, old, new, rev):
1658 ReferenceChange.__init__(
1659 self, environment,
1660 refname=refname, short_refname=short_refname,
1661 old=old, new=new, rev=rev,
1662 )
1663 self.recipients = environment.get_refchange_recipients(self)
5b1d901c
MM
1664 self._single_revision = None
1665
1666 def send_single_combined_email(self, known_added_sha1s):
1667 if not self.environment.combine_when_single_commit:
1668 return None
1669
1670 # In the sadly-all-too-frequent usecase of people pushing only
1671 # one of their commits at a time to a repository, users feel
1672 # the reference change summary emails are noise rather than
1673 # important signal. This is because, in this particular
1674 # usecase, there is a reference change summary email for each
1675 # new commit, and all these summaries do is point out that
1676 # there is one new commit (which can readily be inferred by
1677 # the existence of the individual revision email that is also
1678 # sent). In such cases, our users prefer there to be a combined
1679 # reference change summary/new revision email.
1680 #
1681 # So, if the change is an update and it doesn't discard any
1682 # commits, and it adds exactly one non-merge commit (gerrit
1683 # forces a workflow where every commit is individually merged
1684 # and the git-multimail hook fired off for just this one
1685 # change), then we send a combined refchange/revision email.
1686 try:
1687 # If this change is a reference update that doesn't discard
1688 # any commits...
1689 if self.change_type != 'update':
1690 return None
1691
1692 if read_git_lines(
1693 ['merge-base', self.old.sha1, self.new.sha1]
1694 ) != [self.old.sha1]:
1695 return None
1696
1697 # Check if this update introduced exactly one non-merge
1698 # commit:
1699
1700 def split_line(line):
1701 """Split line into (sha1, [parent,...])."""
1702
1703 words = line.split()
1704 return (words[0], words[1:])
1705
1706 # Get the new commits introduced by the push as a list of
1707 # (sha1, [parent,...])
1708 new_commits = [
1709 split_line(line)
1710 for line in read_git_lines(
1711 [
1712 'log', '-3', '--format=%H %P',
1713 '%s..%s' % (self.old.sha1, self.new.sha1),
1714 ]
1715 )
1716 ]
1717
1718 if not new_commits:
1719 return None
1720
1721 # If the newest commit is a merge, save it for a later check
1722 # but otherwise ignore it
1723 merge = None
1724 tot = len(new_commits)
1725 if len(new_commits[0][1]) > 1:
1726 merge = new_commits[0][0]
1727 del new_commits[0]
1728
1729 # Our primary check: we can't combine if more than one commit
1730 # is introduced. We also currently only combine if the new
1731 # commit is a non-merge commit, though it may make sense to
1732 # combine if it is a merge as well.
1733 if not (
4b1fd356
MM
1734 len(new_commits) == 1 and
1735 len(new_commits[0][1]) == 1 and
1736 new_commits[0][0] in known_added_sha1s
5b1d901c
MM
1737 ):
1738 return None
1739
1740 # We do not want to combine revision and refchange emails if
1741 # those go to separate locations.
1742 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1743 if rev.recipients != self.recipients:
1744 return None
1745
1746 # We ignored the newest commit if it was just a merge of the one
1747 # commit being introduced. But we don't want to ignore that
1748 # merge commit it it involved conflict resolutions. Check that.
1749 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1750 return None
1751
1752 # We can combine the refchange and one new revision emails
1753 # into one. Return the Revision that a combined email should
1754 # be sent about.
1755 return rev
1756 except CommandError:
1757 # Cannot determine number of commits in old..new or new..old;
1758 # don't combine reference/revision emails:
1759 return None
1760
1761 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1762 values = revision.get_values()
1763 if extra_header_values:
1764 values.update(extra_header_values)
1765 if 'subject' not in extra_header_values:
1766 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1767
1768 self._single_revision = revision
4b1fd356 1769 self._contains_diff()
5b1d901c
MM
1770 self.header_template = COMBINED_HEADER_TEMPLATE
1771 self.intro_template = COMBINED_INTRO_TEMPLATE
1772 self.footer_template = COMBINED_FOOTER_TEMPLATE
17130a70
MM
1773
1774 def revision_gen_link(base_url):
1775 # revision is used only to generate the body, and
1776 # _content_type is set while generating headers. Get it
1777 # from the BranchChange object.
1778 revision._content_type = self._content_type
1779 return revision.generate_browse_link(base_url)
1780 self.generate_browse_link = revision_gen_link
5b1d901c
MM
1781 for line in self.generate_email(push, body_filter, values):
1782 yield line
1783
1784 def generate_email_body(self, push):
1785 '''Call the appropriate body generation routine.
1786
1787 If this is a combined refchange/revision email, the special logic
1788 for handling this combined email comes from this function. For
1789 other cases, we just use the normal handling.'''
1790
1791 # If self._single_revision isn't set; don't override
1792 if not self._single_revision:
1793 for line in super(BranchChange, self).generate_email_body(push):
1794 yield line
1795 return
1796
1797 # This is a combined refchange/revision email; we first provide
1798 # some info from the refchange portion, and then call the revision
1799 # generate_email_body function to handle the revision portion.
1800 adds = list(generate_summaries(
1801 '--topo-order', '--reverse', '%s..%s'
1802 % (self.old.commit_sha1, self.new.commit_sha1,)
1803 ))
1804
1805 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1806 for (sha1, subject) in adds:
1807 yield self.expand(
1808 BRIEF_SUMMARY_TEMPLATE, action='new',
1809 rev_short=sha1, text=subject,
1810 )
1811
1812 yield self._single_revision.rev.short + " is described below\n"
1813 yield '\n'
1814
1815 for line in self._single_revision.generate_email_body(push):
1816 yield line
bc501f69
MH
1817
1818
1819class AnnotatedTagChange(ReferenceChange):
1820 refname_type = 'annotated tag'
1821
1822 def __init__(self, environment, refname, short_refname, old, new, rev):
1823 ReferenceChange.__init__(
1824 self, environment,
1825 refname=refname, short_refname=short_refname,
1826 old=old, new=new, rev=rev,
1827 )
1828 self.recipients = environment.get_announce_recipients(self)
1829 self.show_shortlog = environment.announce_show_shortlog
1830
1831 ANNOTATED_TAG_FORMAT = (
1832 '%(*objectname)\n'
1833 '%(*objecttype)\n'
1834 '%(taggername)\n'
1835 '%(taggerdate)'
1836 )
1837
1838 def describe_tag(self, push):
1839 """Describe the new value of an annotated tag."""
1840
1841 # Use git for-each-ref to pull out the individual fields from
1842 # the tag
1843 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1844 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1845 )
1846
1847 yield self.expand(
1848 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1849 rev_short=tagobject, text='(%s)' % (tagtype,),
1850 )
1851 if tagtype == 'commit':
1852 # If the tagged object is a commit, then we assume this is a
1853 # release, and so we calculate which tag this tag is
1854 # replacing
1855 try:
1856 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1857 except CommandError:
1858 prevtag = None
1859 if prevtag:
7c554311 1860 yield ' replaces %s\n' % (prevtag,)
bc501f69
MH
1861 else:
1862 prevtag = None
7c554311 1863 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
bc501f69 1864
7c554311
MM
1865 yield ' by %s\n' % (tagger,)
1866 yield ' on %s\n' % (tagged,)
bc501f69
MH
1867 yield '\n'
1868
1869 # Show the content of the tag message; this might contain a
1870 # change log or release notes so is worth displaying.
1871 yield LOGBEGIN
1872 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1873 contents = contents[contents.index('\n') + 1:]
1874 if contents and contents[-1][-1:] != '\n':
1875 contents.append('\n')
1876 for line in contents:
1877 yield line
1878
1879 if self.show_shortlog and tagtype == 'commit':
1880 # Only commit tags make sense to have rev-list operations
1881 # performed on them
1882 yield '\n'
1883 if prevtag:
1884 # Show changes since the previous release
1885 revlist = read_git_output(
1886 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1887 keepends=True,
1888 )
1889 else:
1890 # No previous tag, show all the changes since time
1891 # began
1892 revlist = read_git_output(
1893 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1894 keepends=True,
1895 )
1896 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1897 yield line
1898
1899 yield LOGEND
1900 yield '\n'
1901
1902 def generate_create_summary(self, push):
1903 """Called for the creation of an annotated tag."""
1904
1905 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1906 yield line
1907
1908 for line in self.describe_tag(push):
1909 yield line
1910
1911 def generate_update_summary(self, push):
1912 """Called for the update of an annotated tag.
1913
1914 This is probably a rare event and may not even be allowed."""
1915
1916 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1917 yield line
1918
1919 for line in self.describe_tag(push):
1920 yield line
1921
1922 def generate_delete_summary(self, push):
1923 """Called when a non-annotated reference is updated."""
1924
1925 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1926 yield line
1927
1928 yield self.expand(' tag was %(oldrev_short)s\n')
1929 yield '\n'
1930
1931
1932class NonAnnotatedTagChange(ReferenceChange):
1933 refname_type = 'tag'
1934
1935 def __init__(self, environment, refname, short_refname, old, new, rev):
1936 ReferenceChange.__init__(
1937 self, environment,
1938 refname=refname, short_refname=short_refname,
1939 old=old, new=new, rev=rev,
1940 )
1941 self.recipients = environment.get_refchange_recipients(self)
1942
1943 def generate_create_summary(self, push):
1944 """Called for the creation of an annotated tag."""
1945
1946 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1947 yield line
1948
1949 def generate_update_summary(self, push):
1950 """Called when a non-annotated reference is updated."""
1951
1952 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1953 yield line
1954
1955 def generate_delete_summary(self, push):
1956 """Called when a non-annotated reference is updated."""
1957
1958 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1959 yield line
1960
1961 for line in ReferenceChange.generate_delete_summary(self, push):
1962 yield line
1963
1964
1965class OtherReferenceChange(ReferenceChange):
1966 refname_type = 'reference'
1967
1968 def __init__(self, environment, refname, short_refname, old, new, rev):
1969 # We use the full refname as short_refname, because otherwise
1970 # the full name of the reference would not be obvious from the
1971 # text of the email.
1972 ReferenceChange.__init__(
1973 self, environment,
1974 refname=refname, short_refname=refname,
1975 old=old, new=new, rev=rev,
1976 )
1977 self.recipients = environment.get_refchange_recipients(self)
1978
1979
1980class Mailer(object):
1981 """An object that can send emails."""
1982
7c554311
MM
1983 def __init__(self, environment):
1984 self.environment = environment
1985
99177b34
MM
1986 def close(self):
1987 pass
1988
bc501f69
MH
1989 def send(self, lines, to_addrs):
1990 """Send an email consisting of lines.
1991
1992 lines must be an iterable over the lines constituting the
1993 header and body of the email. to_addrs is a list of recipient
1994 addresses (can be needed even if lines already contains a
1995 "To:" field). It can be either a string (comma-separated list
1996 of email addresses) or a Python list of individual email
1997 addresses.
1998
1999 """
2000
2001 raise NotImplementedError()
2002
2003
2004class SendMailer(Mailer):
b513f71f 2005 """Send emails using 'sendmail -oi -t'."""
bc501f69
MH
2006
2007 SENDMAIL_CANDIDATES = [
2008 '/usr/sbin/sendmail',
2009 '/usr/lib/sendmail',
2010 ]
2011
2012 @staticmethod
2013 def find_sendmail():
2014 for path in SendMailer.SENDMAIL_CANDIDATES:
2015 if os.access(path, os.X_OK):
2016 return path
2017 else:
2018 raise ConfigurationException(
2019 'No sendmail executable found. '
2020 'Try setting multimailhook.sendmailCommand.'
2021 )
2022
7c554311 2023 def __init__(self, environment, command=None, envelopesender=None):
bc501f69
MH
2024 """Construct a SendMailer instance.
2025
2026 command should be the command and arguments used to invoke
2027 sendmail, as a list of strings. If an envelopesender is
2028 provided, it will also be passed to the command, via '-f
2029 envelopesender'."""
7c554311 2030 super(SendMailer, self).__init__(environment)
bc501f69
MH
2031 if command:
2032 self.command = command[:]
2033 else:
b513f71f 2034 self.command = [self.find_sendmail(), '-oi', '-t']
bc501f69
MH
2035
2036 if envelopesender:
2037 self.command.extend(['-f', envelopesender])
2038
2039 def send(self, lines, to_addrs):
2040 try:
2041 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
4b1fd356 2042 except OSError:
7c554311 2043 self.environment.get_logger().error(
4b1fd356
MM
2044 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
2045 '*** %s\n' % sys.exc_info()[1] +
2046 '*** Try setting multimailhook.mailer to "smtp"\n' +
bc501f69
MH
2047 '*** to send emails without using the sendmail command.\n'
2048 )
2049 sys.exit(1)
2050 try:
4b1fd356 2051 lines = (str_to_bytes(line) for line in lines)
bc501f69 2052 p.stdin.writelines(lines)
4b1fd356 2053 except Exception:
7c554311 2054 self.environment.get_logger().error(
bc501f69
MH
2055 '*** Error while generating commit email\n'
2056 '*** - mail sending aborted.\n'
2057 )
7c554311 2058 if hasattr(p, 'terminate'):
5b1d901c
MM
2059 # subprocess.terminate() is not available in Python 2.4
2060 p.terminate()
7c554311
MM
2061 else:
2062 import signal
2063 os.kill(p.pid, signal.SIGTERM)
4b1fd356 2064 raise
bc501f69
MH
2065 else:
2066 p.stdin.close()
2067 retcode = p.wait()
2068 if retcode:
2069 raise CommandError(self.command, retcode)
2070
2071
2072class SMTPMailer(Mailer):
2073 """Send emails using Python's smtplib."""
2074
7c554311
MM
2075 def __init__(self, environment,
2076 envelopesender, smtpserver,
5b1d901c
MM
2077 smtpservertimeout=10.0, smtpserverdebuglevel=0,
2078 smtpencryption='none',
2079 smtpuser='', smtppass='',
4453d76c 2080 smtpcacerts=''
5b1d901c 2081 ):
7c554311 2082 super(SMTPMailer, self).__init__(environment)
bc501f69 2083 if not envelopesender:
7c554311 2084 self.environment.get_logger().error(
bc501f69
MH
2085 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2086 'please set either multimailhook.envelopeSender or user.email\n'
2087 )
2088 sys.exit(1)
5b1d901c
MM
2089 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2090 raise ConfigurationException(
2091 'Cannot use SMTPMailer with security option ssl '
2092 'without options username and password.'
2093 )
bc501f69
MH
2094 self.envelopesender = envelopesender
2095 self.smtpserver = smtpserver
5b1d901c
MM
2096 self.smtpservertimeout = smtpservertimeout
2097 self.smtpserverdebuglevel = smtpserverdebuglevel
2098 self.security = smtpencryption
2099 self.username = smtpuser
2100 self.password = smtppass
4453d76c 2101 self.smtpcacerts = smtpcacerts
99177b34 2102 self.loggedin = False
bc501f69 2103 try:
5bdb7a78
MM
2104 def call(klass, server, timeout):
2105 try:
2106 return klass(server, timeout=timeout)
2107 except TypeError:
2108 # Old Python versions do not have timeout= argument.
2109 return klass(server)
5b1d901c 2110 if self.security == 'none':
5bdb7a78 2111 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
5b1d901c 2112 elif self.security == 'ssl':
4453d76c
MM
2113 if self.smtpcacerts:
2114 raise smtplib.SMTPException(
2115 "Checking certificate is not supported for ssl, prefer starttls"
2116 )
5bdb7a78 2117 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
5b1d901c 2118 elif self.security == 'tls':
4453d76c 2119 if 'ssl' not in sys.modules:
7c554311 2120 self.environment.get_logger().error(
4453d76c
MM
2121 '*** Your Python version does not have the ssl library installed\n'
2122 '*** smtpEncryption=tls is not available.\n'
2123 '*** Either upgrade Python to 2.6 or later\n'
2124 ' or use git_multimail.py version 1.2.\n')
5b1d901c
MM
2125 if ':' not in self.smtpserver:
2126 self.smtpserver += ':587' # default port for TLS
5bdb7a78 2127 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
4453d76c
MM
2128 # start: ehlo + starttls
2129 # equivalent to
2130 # self.smtp.ehlo()
2131 # self.smtp.starttls()
2132 # with acces to the ssl layer
5b1d901c 2133 self.smtp.ehlo()
4453d76c
MM
2134 if not self.smtp.has_extn("starttls"):
2135 raise smtplib.SMTPException("STARTTLS extension not supported by server")
2136 resp, reply = self.smtp.docmd("STARTTLS")
2137 if resp != 220:
2138 raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2139 if self.smtpcacerts:
2140 self.smtp.sock = ssl.wrap_socket(
2141 self.smtp.sock,
2142 ca_certs=self.smtpcacerts,
2143 cert_reqs=ssl.CERT_REQUIRED
2144 )
2145 else:
2146 self.smtp.sock = ssl.wrap_socket(
2147 self.smtp.sock,
2148 cert_reqs=ssl.CERT_NONE
2149 )
7c554311 2150 self.environment.get_logger().error(
4453d76c
MM
2151 '*** Warning, the server certificat is not verified (smtp) ***\n'
2152 '*** set the option smtpCACerts ***\n'
2153 )
2154 if not hasattr(self.smtp.sock, "read"):
2155 # using httplib.FakeSocket with Python 2.5.x or earlier
2156 self.smtp.sock.read = self.smtp.sock.recv
2157 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2158 self.smtp.helo_resp = None
2159 self.smtp.ehlo_resp = None
2160 self.smtp.esmtp_features = {}
2161 self.smtp.does_esmtp = 0
2162 # end: ehlo + starttls
5b1d901c
MM
2163 self.smtp.ehlo()
2164 else:
2165 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2166 sys.exit(1)
2167 if self.smtpserverdebuglevel > 0:
2168 sys.stdout.write(
2169 "*** Setting debug on for SMTP server connection (%s) ***\n"
2170 % self.smtpserverdebuglevel)
2171 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
4b1fd356 2172 except Exception:
7c554311 2173 self.environment.get_logger().error(
5b1d901c 2174 '*** Error establishing SMTP connection to %s ***\n'
7c554311
MM
2175 '*** %s\n'
2176 % (self.smtpserver, sys.exc_info()[1]))
bc501f69
MH
2177 sys.exit(1)
2178
99177b34 2179 def close(self):
5b1d901c
MM
2180 if hasattr(self, 'smtp'):
2181 self.smtp.quit()
4453d76c 2182 del self.smtp
bc501f69 2183
99177b34
MM
2184 def __del__(self):
2185 self.close()
2186
bc501f69
MH
2187 def send(self, lines, to_addrs):
2188 try:
5b1d901c 2189 if self.username or self.password:
99177b34
MM
2190 if not self.loggedin:
2191 self.smtp.login(self.username, self.password)
2192 self.loggedin = True
bc501f69
MH
2193 msg = ''.join(lines)
2194 # turn comma-separated list into Python list if needed.
4453d76c 2195 if is_string(to_addrs):
bc501f69
MH
2196 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2197 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
99177b34
MM
2198 except socket.timeout:
2199 self.environment.get_logger().error(
2200 '*** Error sending email ***\n'
2201 '*** SMTP server timed out (timeout is %s)\n'
2202 % self.smtpservertimeout)
4453d76c 2203 except smtplib.SMTPResponseException:
4453d76c 2204 err = sys.exc_info()[1]
7c554311
MM
2205 self.environment.get_logger().error(
2206 '*** Error sending email ***\n'
2207 '*** Error %d: %s\n'
2208 % (err.smtp_code, bytes_to_str(err.smtp_error)))
4453d76c
MM
2209 try:
2210 smtp = self.smtp
2211 # delete the field before quit() so that in case of
2212 # error, self.smtp is deleted anyway.
2213 del self.smtp
2214 smtp.quit()
2215 except:
7c554311
MM
2216 self.environment.get_logger().error(
2217 '*** Error closing the SMTP connection ***\n'
2218 '*** Exiting anyway ... ***\n'
2219 '*** %s\n' % sys.exc_info()[1])
bc501f69
MH
2220 sys.exit(1)
2221
2222
2223class OutputMailer(Mailer):
2224 """Write emails to an output stream, bracketed by lines of '=' characters.
2225
2226 This is intended for debugging purposes."""
2227
2228 SEPARATOR = '=' * 75 + '\n'
2229
99177b34
MM
2230 def __init__(self, f, environment=None):
2231 super(OutputMailer, self).__init__(environment=environment)
bc501f69
MH
2232 self.f = f
2233
2234 def send(self, lines, to_addrs):
4b1fd356
MM
2235 write_str(self.f, self.SEPARATOR)
2236 for line in lines:
2237 write_str(self.f, line)
2238 write_str(self.f, self.SEPARATOR)
bc501f69
MH
2239
2240
2241def get_git_dir():
2242 """Determine GIT_DIR.
2243
2244 Determine GIT_DIR either from the GIT_DIR environment variable or
2245 from the working directory, using Git's usual rules."""
2246
2247 try:
2248 return read_git_output(['rev-parse', '--git-dir'])
2249 except CommandError:
2250 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2251 sys.exit(1)
2252
2253
2254class Environment(object):
2255 """Describes the environment in which the push is occurring.
2256
2257 An Environment object encapsulates information about the local
2258 environment. For example, it knows how to determine:
2259
2260 * the name of the repository to which the push occurred
2261
2262 * what user did the push
2263
2264 * what users want to be informed about various types of changes.
2265
2266 An Environment object is expected to have the following methods:
2267
2268 get_repo_shortname()
2269
2270 Return a short name for the repository, for display
2271 purposes.
2272
2273 get_repo_path()
2274
2275 Return the absolute path to the Git repository.
2276
2277 get_emailprefix()
2278
2279 Return a string that will be prefixed to every email's
2280 subject.
2281
2282 get_pusher()
2283
2284 Return the username of the person who pushed the changes.
2285 This value is used in the email body to indicate who
2286 pushed the change.
2287
2288 get_pusher_email() (may return None)
2289
2290 Return the email address of the person who pushed the
2291 changes. The value should be a single RFC 2822 email
2292 address as a string; e.g., "Joe User <user@example.com>"
2293 if available, otherwise "user@example.com". If set, the
2294 value is used as the Reply-To address for refchange
2295 emails. If it is impossible to determine the pusher's
2296 email, this attribute should be set to None (in which case
2297 no Reply-To header will be output).
2298
2299 get_sender()
2300
2301 Return the address to be used as the 'From' email address
2302 in the email envelope.
2303
4b1fd356 2304 get_fromaddr(change=None)
bc501f69
MH
2305
2306 Return the 'From' email address used in the email 'From:'
4b1fd356
MM
2307 headers. If the change is known when this function is
2308 called, it is passed in as the 'change' parameter. (May
2309 be a full RFC 2822 email address like 'Joe User
2310 <user@example.com>'.)
bc501f69
MH
2311
2312 get_administrator()
2313
2314 Return the name and/or email of the repository
2315 administrator. This value is used in the footer as the
2316 person to whom requests to be removed from the
2317 notification list should be sent. Ideally, it should
2318 include a valid email address.
2319
2320 get_reply_to_refchange()
2321 get_reply_to_commit()
2322
2323 Return the address to use in the email "Reply-To" header,
2324 as a string. These can be an RFC 2822 email address, or
2325 None to omit the "Reply-To" header.
2326 get_reply_to_refchange() is used for refchange emails;
2327 get_reply_to_commit() is used for individual commit
2328 emails.
2329
4b1fd356
MM
2330 get_ref_filter_regex()
2331
2332 Return a tuple -- a compiled regex, and a boolean indicating
2333 whether the regex picks refs to include (if False, the regex
2334 matches on refs to exclude).
2335
2336 get_default_ref_ignore_regex()
2337
2338 Return a regex that should be ignored for both what emails
2339 to send and when computing what commits are considered new
2340 to the repository. Default is "^refs/notes/".
2341
7c554311
MM
2342 get_max_subject_length()
2343
2344 Return an int giving the maximal length for the subject
2345 (git log --oneline).
2346
bc501f69
MH
2347 They should also define the following attributes:
2348
2349 announce_show_shortlog (bool)
2350
2351 True iff announce emails should include a shortlog.
2352
4b1fd356
MM
2353 commit_email_format (string)
2354
2355 If "html", generate commit emails in HTML instead of plain text
2356 used by default.
2357
4453d76c
MM
2358 html_in_intro (bool)
2359 html_in_footer (bool)
2360
2361 When generating HTML emails, the introduction (respectively,
2362 the footer) will be HTML-escaped iff html_in_intro (respectively,
2363 the footer) is true. When false, only the values used to expand
2364 the template are escaped.
2365
5b1d901c
MM
2366 refchange_showgraph (bool)
2367
2368 True iff refchanges emails should include a detailed graph.
2369
bc501f69
MH
2370 refchange_showlog (bool)
2371
2372 True iff refchanges emails should include a detailed log.
2373
2374 diffopts (list of strings)
2375
2376 The options that should be passed to 'git diff' for the
2377 summary email. The value should be a list of strings
2378 representing words to be passed to the command.
2379
5b1d901c
MM
2380 graphopts (list of strings)
2381
2382 Analogous to diffopts, but contains options passed to
2383 'git log --graph' when generating the detailed graph for
2384 a set of commits (see refchange_showgraph)
2385
bc501f69
MH
2386 logopts (list of strings)
2387
2388 Analogous to diffopts, but contains options passed to
2389 'git log' when generating the detailed log for a set of
2390 commits (see refchange_showlog)
2391
b513f71f
MH
2392 commitlogopts (list of strings)
2393
2394 The options that should be passed to 'git log' for each
2395 commit mail. The value should be a list of strings
2396 representing words to be passed to the command.
2397
4b1fd356
MM
2398 date_substitute (string)
2399
2400 String to be used in substitution for 'Date:' at start of
2401 line in the output of 'git log'.
2402
5b1d901c
MM
2403 quiet (bool)
2404 On success do not write to stderr
2405
2406 stdout (bool)
2407 Write email to stdout rather than emailing. Useful for debugging
2408
2409 combine_when_single_commit (bool)
2410
2411 True if a combined email should be produced when a single
2412 new commit is pushed to a branch, False otherwise.
2413
4b1fd356
MM
2414 from_refchange, from_commit (strings)
2415
2416 Addresses to use for the From: field for refchange emails
2417 and commit emails respectively. Set from
2418 multimailhook.fromRefchange and multimailhook.fromCommit
2419 by ConfigEnvironmentMixin.
2420
7c554311
MM
2421 log_file, error_log_file, debug_log_file (string)
2422
2423 Name of a file to which logs should be sent.
2424
2425 verbose (int)
2426
2427 How verbose the system should be.
2428 - 0 (default): show info, errors, ...
2429 - 1 : show basic debug info
bc501f69
MH
2430 """
2431
2432 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2433
2434 def __init__(self, osenv=None):
2435 self.osenv = osenv or os.environ
2436 self.announce_show_shortlog = False
4b1fd356 2437 self.commit_email_format = "text"
4453d76c
MM
2438 self.html_in_intro = False
2439 self.html_in_footer = False
2440 self.commitBrowseURL = None
bc501f69 2441 self.maxcommitemails = 500
99177b34 2442 self.excludemergerevisions = False
bc501f69 2443 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
5b1d901c 2444 self.graphopts = ['--oneline', '--decorate']
bc501f69 2445 self.logopts = []
5b1d901c 2446 self.refchange_showgraph = False
bc501f69 2447 self.refchange_showlog = False
b513f71f 2448 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
4b1fd356 2449 self.date_substitute = 'AuthorDate: '
5b1d901c
MM
2450 self.quiet = False
2451 self.stdout = False
2452 self.combine_when_single_commit = True
7c554311 2453 self.logger = None
bc501f69
MH
2454
2455 self.COMPUTED_KEYS = [
2456 'administrator',
2457 'charset',
2458 'emailprefix',
bc501f69
MH
2459 'pusher',
2460 'pusher_email',
2461 'repo_path',
2462 'repo_shortname',
2463 'sender',
2464 ]
2465
2466 self._values = None
2467
7c554311
MM
2468 def get_logger(self):
2469 """Get (possibly creates) the logger associated to this environment."""
2470 if self.logger is None:
2471 self.logger = Logger(self)
2472 return self.logger
2473
bc501f69
MH
2474 def get_repo_shortname(self):
2475 """Use the last part of the repo path, with ".git" stripped off if present."""
2476
2477 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2478 m = self.REPO_NAME_RE.match(basename)
2479 if m:
2480 return m.group('name')
2481 else:
2482 return basename
2483
2484 def get_pusher(self):
2485 raise NotImplementedError()
2486
2487 def get_pusher_email(self):
2488 return None
2489
4b1fd356 2490 def get_fromaddr(self, change=None):
5b1d901c
MM
2491 config = Config('user')
2492 fromname = config.get('name', default='')
2493 fromemail = config.get('email', default='')
2494 if fromemail:
2495 return formataddr([fromname, fromemail])
2496 return self.get_sender()
2497
bc501f69
MH
2498 def get_administrator(self):
2499 return 'the administrator of this repository'
2500
2501 def get_emailprefix(self):
2502 return ''
2503
2504 def get_repo_path(self):
2505 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2506 path = get_git_dir()
2507 else:
2508 path = read_git_output(['rev-parse', '--show-toplevel'])
2509 return os.path.abspath(path)
2510
2511 def get_charset(self):
2512 return CHARSET
2513
2514 def get_values(self):
5b1d901c 2515 """Return a dictionary {keyword: expansion} for this Environment.
bc501f69
MH
2516
2517 This method is called by Change._compute_values(). The keys
2518 in the returned dictionary are available to be used in any of
2519 the templates. The dictionary is created by calling
2520 self.get_NAME() for each of the attributes named in
2521 COMPUTED_KEYS and recording those that do not return None.
2522 The return value is always a new dictionary."""
2523
2524 if self._values is None:
4453d76c 2525 values = {'': ''} # %()s expands to the empty string.
bc501f69
MH
2526
2527 for key in self.COMPUTED_KEYS:
2528 value = getattr(self, 'get_%s' % (key,))()
2529 if value is not None:
2530 values[key] = value
2531
2532 self._values = values
2533
2534 return self._values.copy()
2535
2536 def get_refchange_recipients(self, refchange):
2537 """Return the recipients for notifications about refchange.
2538
2539 Return the list of email addresses to which notifications
2540 about the specified ReferenceChange should be sent."""
2541
2542 raise NotImplementedError()
2543
2544 def get_announce_recipients(self, annotated_tag_change):
2545 """Return the recipients for notifications about annotated_tag_change.
2546
2547 Return the list of email addresses to which notifications
2548 about the specified AnnotatedTagChange should be sent."""
2549
2550 raise NotImplementedError()
2551
2552 def get_reply_to_refchange(self, refchange):
2553 return self.get_pusher_email()
2554
2555 def get_revision_recipients(self, revision):
2556 """Return the recipients for messages about revision.
2557
2558 Return the list of email addresses to which notifications
2559 about the specified Revision should be sent. This method
2560 could be overridden, for example, to take into account the
2561 contents of the revision when deciding whom to notify about
2562 it. For example, there could be a scheme for users to express
2563 interest in particular files or subdirectories, and only
2564 receive notification emails for revisions that affecting those
2565 files."""
2566
2567 raise NotImplementedError()
2568
2569 def get_reply_to_commit(self, revision):
2570 return revision.author
2571
4b1fd356
MM
2572 def get_default_ref_ignore_regex(self):
2573 # The commit messages of git notes are essentially meaningless
2574 # and "filenames" in git notes commits are an implementational
2575 # detail that might surprise users at first. As such, we
2576 # would need a completely different method for handling emails
2577 # of git notes in order for them to be of benefit for users,
2578 # which we simply do not have right now.
2579 return "^refs/notes/"
2580
7c554311
MM
2581 def get_max_subject_length(self):
2582 """Return the maximal subject line (git log --oneline) length.
2583 Longer subject lines will be truncated."""
2584 raise NotImplementedError()
2585
bc501f69
MH
2586 def filter_body(self, lines):
2587 """Filter the lines intended for an email body.
2588
2589 lines is an iterable over the lines that would go into the
2590 email body. Filter it (e.g., limit the number of lines, the
2591 line length, character set, etc.), returning another iterable.
2592 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2593 for classes implementing this functionality."""
2594
2595 return lines
2596
5b1d901c
MM
2597 def log_msg(self, msg):
2598 """Write the string msg on a log file or on stderr.
2599
2600 Sends the text to stderr by default, override to change the behavior."""
7c554311 2601 self.get_logger().info(msg)
5b1d901c
MM
2602
2603 def log_warning(self, msg):
2604 """Write the string msg on a log file or on stderr.
2605
2606 Sends the text to stderr by default, override to change the behavior."""
7c554311 2607 self.get_logger().warning(msg)
5b1d901c
MM
2608
2609 def log_error(self, msg):
2610 """Write the string msg on a log file or on stderr.
2611
2612 Sends the text to stderr by default, override to change the behavior."""
7c554311
MM
2613 self.get_logger().error(msg)
2614
2615 def check(self):
2616 pass
5b1d901c 2617
bc501f69
MH
2618
2619class ConfigEnvironmentMixin(Environment):
2620 """A mixin that sets self.config to its constructor's config argument.
2621
2622 This class's constructor consumes the "config" argument.
2623
2624 Mixins that need to inspect the config should inherit from this
2625 class (1) to make sure that "config" is still in the constructor
2626 arguments with its own constructor runs and/or (2) to be sure that
2627 self.config is set after construction."""
2628
2629 def __init__(self, config, **kw):
2630 super(ConfigEnvironmentMixin, self).__init__(**kw)
2631 self.config = config
2632
2633
2634class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2635 """An Environment that reads most of its information from "git config"."""
2636
4b1fd356
MM
2637 @staticmethod
2638 def forbid_field_values(name, value, forbidden):
2639 for forbidden_val in forbidden:
2640 if value is not None and value.lower() == forbidden:
2641 raise ConfigurationException(
2642 '"%s" is not an allowed setting for %s' % (value, name)
2643 )
2644
bc501f69
MH
2645 def __init__(self, config, **kw):
2646 super(ConfigOptionsEnvironmentMixin, self).__init__(
2647 config=config, **kw
2648 )
2649
5b1d901c
MM
2650 for var, cfg in (
2651 ('announce_show_shortlog', 'announceshortlog'),
2652 ('refchange_showgraph', 'refchangeShowGraph'),
2653 ('refchange_showlog', 'refchangeshowlog'),
2654 ('quiet', 'quiet'),
2655 ('stdout', 'stdout'),
2656 ):
2657 val = config.get_bool(cfg)
2658 if val is not None:
2659 setattr(self, var, val)
bc501f69 2660
4b1fd356
MM
2661 commit_email_format = config.get('commitEmailFormat')
2662 if commit_email_format is not None:
2663 if commit_email_format != "html" and commit_email_format != "text":
2664 self.log_warning(
2665 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2666 commit_email_format +
2667 '*** Expected either "text" or "html". Ignoring.\n'
2668 )
2669 else:
2670 self.commit_email_format = commit_email_format
2671
4453d76c
MM
2672 html_in_intro = config.get_bool('htmlInIntro')
2673 if html_in_intro is not None:
2674 self.html_in_intro = html_in_intro
2675
2676 html_in_footer = config.get_bool('htmlInFooter')
2677 if html_in_footer is not None:
2678 self.html_in_footer = html_in_footer
2679
2680 self.commitBrowseURL = config.get('commitBrowseURL')
2681
99177b34
MM
2682 self.excludemergerevisions = config.get('excludeMergeRevisions')
2683
bc501f69
MH
2684 maxcommitemails = config.get('maxcommitemails')
2685 if maxcommitemails is not None:
2686 try:
2687 self.maxcommitemails = int(maxcommitemails)
2688 except ValueError:
5b1d901c 2689 self.log_warning(
4b1fd356
MM
2690 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2691 % maxcommitemails +
2692 '*** Expected a number. Ignoring.\n'
bc501f69
MH
2693 )
2694
2695 diffopts = config.get('diffopts')
2696 if diffopts is not None:
2697 self.diffopts = shlex.split(diffopts)
2698
5b1d901c
MM
2699 graphopts = config.get('graphOpts')
2700 if graphopts is not None:
2701 self.graphopts = shlex.split(graphopts)
2702
bc501f69
MH
2703 logopts = config.get('logopts')
2704 if logopts is not None:
2705 self.logopts = shlex.split(logopts)
2706
b513f71f
MH
2707 commitlogopts = config.get('commitlogopts')
2708 if commitlogopts is not None:
2709 self.commitlogopts = shlex.split(commitlogopts)
2710
4b1fd356
MM
2711 date_substitute = config.get('dateSubstitute')
2712 if date_substitute == 'none':
2713 self.date_substitute = None
2714 elif date_substitute is not None:
2715 self.date_substitute = date_substitute
2716
bc501f69
MH
2717 reply_to = config.get('replyTo')
2718 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
4b1fd356
MM
2719 self.forbid_field_values('replyToRefchange',
2720 self.__reply_to_refchange,
2721 ['author'])
bc501f69
MH
2722 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2723
4b1fd356
MM
2724 self.from_refchange = config.get('fromRefchange')
2725 self.forbid_field_values('fromRefchange',
2726 self.from_refchange,
2727 ['author', 'none'])
2728 self.from_commit = config.get('fromCommit')
2729 self.forbid_field_values('fromCommit',
2730 self.from_commit,
2731 ['none'])
2732
5b1d901c
MM
2733 combine = config.get_bool('combineWhenSingleCommit')
2734 if combine is not None:
2735 self.combine_when_single_commit = combine
2736
7c554311
MM
2737 self.log_file = config.get('logFile', default=None)
2738 self.error_log_file = config.get('errorLogFile', default=None)
2739 self.debug_log_file = config.get('debugLogFile', default=None)
2740 if config.get_bool('Verbose', default=False):
2741 self.verbose = 1
2742 else:
2743 self.verbose = 0
2744
bc501f69
MH
2745 def get_administrator(self):
2746 return (
4b1fd356
MM
2747 self.config.get('administrator') or
2748 self.get_sender() or
2749 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
bc501f69
MH
2750 )
2751
2752 def get_repo_shortname(self):
2753 return (
4b1fd356
MM
2754 self.config.get('reponame') or
2755 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
bc501f69
MH
2756 )
2757
2758 def get_emailprefix(self):
2759 emailprefix = self.config.get('emailprefix')
5b1d901c
MM
2760 if emailprefix is not None:
2761 emailprefix = emailprefix.strip()
2762 if emailprefix:
7c554311 2763 emailprefix += ' '
bc501f69 2764 else:
7c554311
MM
2765 emailprefix = '[%(repo_shortname)s] '
2766 short_name = self.get_repo_shortname()
2767 try:
2768 return emailprefix % {'repo_shortname': short_name}
2769 except:
2770 self.get_logger().error(
2771 '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix +
2772 '*** %s\n' % sys.exc_info()[1] +
2773 "*** Only the '%(repo_shortname)s' placeholder is allowed\n"
2774 )
2775 raise ConfigurationException(
2776 '"%s" is not an allowed setting for emailPrefix' % emailprefix
2777 )
bc501f69
MH
2778
2779 def get_sender(self):
2780 return self.config.get('envelopesender')
2781
4b1fd356
MM
2782 def process_addr(self, addr, change):
2783 if addr.lower() == 'author':
2784 if hasattr(change, 'author'):
2785 return change.author
2786 else:
2787 return None
2788 elif addr.lower() == 'pusher':
2789 return self.get_pusher_email()
2790 elif addr.lower() == 'none':
2791 return None
2792 else:
2793 return addr
2794
2795 def get_fromaddr(self, change=None):
bc501f69 2796 fromaddr = self.config.get('from')
4b1fd356 2797 if change:
7c554311
MM
2798 specific_fromaddr = change.get_specific_fromaddr()
2799 if specific_fromaddr:
2800 fromaddr = specific_fromaddr
4b1fd356
MM
2801 if fromaddr:
2802 fromaddr = self.process_addr(fromaddr, change)
bc501f69
MH
2803 if fromaddr:
2804 return fromaddr
4b1fd356 2805 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
bc501f69
MH
2806
2807 def get_reply_to_refchange(self, refchange):
2808 if self.__reply_to_refchange is None:
2809 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
bc501f69 2810 else:
4b1fd356 2811 return self.process_addr(self.__reply_to_refchange, refchange)
bc501f69
MH
2812
2813 def get_reply_to_commit(self, revision):
2814 if self.__reply_to_commit is None:
2815 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
bc501f69 2816 else:
4b1fd356 2817 return self.process_addr(self.__reply_to_commit, revision)
bc501f69 2818
5b1d901c
MM
2819 def get_scancommitforcc(self):
2820 return self.config.get('scancommitforcc')
2821
bc501f69
MH
2822
2823class FilterLinesEnvironmentMixin(Environment):
2824 """Handle encoding and maximum line length of body lines.
2825
7c554311 2826 email_max_line_length (int or None)
bc501f69
MH
2827
2828 The maximum length of any single line in the email body.
2829 Longer lines are truncated at that length with ' [...]'
2830 appended.
2831
2832 strict_utf8 (bool)
2833
2834 If this field is set to True, then the email body text is
2835 expected to be UTF-8. Any invalid characters are
2836 converted to U+FFFD, the Unicode replacement character
2837 (encoded as UTF-8, of course).
2838
2839 """
2840
7c554311
MM
2841 def __init__(self, strict_utf8=True,
2842 email_max_line_length=500, max_subject_length=500,
2843 **kw):
bc501f69
MH
2844 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2845 self.__strict_utf8 = strict_utf8
7c554311
MM
2846 self.__email_max_line_length = email_max_line_length
2847 self.__max_subject_length = max_subject_length
bc501f69
MH
2848
2849 def filter_body(self, lines):
2850 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2851 if self.__strict_utf8:
4b1fd356
MM
2852 if not PYTHON3:
2853 lines = (line.decode(ENCODING, 'replace') for line in lines)
bc501f69
MH
2854 # Limit the line length in Unicode-space to avoid
2855 # splitting characters:
7c554311
MM
2856 if self.__email_max_line_length > 0:
2857 lines = limit_linelength(lines, self.__email_max_line_length)
4b1fd356
MM
2858 if not PYTHON3:
2859 lines = (line.encode(ENCODING, 'replace') for line in lines)
7c554311
MM
2860 elif self.__email_max_line_length:
2861 lines = limit_linelength(lines, self.__email_max_line_length)
bc501f69
MH
2862
2863 return lines
2864
7c554311
MM
2865 def get_max_subject_length(self):
2866 return self.__max_subject_length
2867
bc501f69
MH
2868
2869class ConfigFilterLinesEnvironmentMixin(
5b1d901c
MM
2870 ConfigEnvironmentMixin,
2871 FilterLinesEnvironmentMixin,
2872 ):
bc501f69
MH
2873 """Handle encoding and maximum line length based on config."""
2874
2875 def __init__(self, config, **kw):
2876 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2877 if strict_utf8 is not None:
2878 kw['strict_utf8'] = strict_utf8
2879
7c554311
MM
2880 email_max_line_length = config.get('emailmaxlinelength')
2881 if email_max_line_length is not None:
2882 kw['email_max_line_length'] = int(email_max_line_length)
2883
2884 max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)
2885 if max_subject_length is not None:
2886 kw['max_subject_length'] = int(max_subject_length)
bc501f69
MH
2887
2888 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2889 config=config, **kw
2890 )
2891
2892
2893class MaxlinesEnvironmentMixin(Environment):
2894 """Limit the email body to a specified number of lines."""
2895
2896 def __init__(self, emailmaxlines, **kw):
2897 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2898 self.__emailmaxlines = emailmaxlines
2899
2900 def filter_body(self, lines):
2901 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
7c554311 2902 if self.__emailmaxlines > 0:
bc501f69
MH
2903 lines = limit_lines(lines, self.__emailmaxlines)
2904 return lines
2905
2906
2907class ConfigMaxlinesEnvironmentMixin(
5b1d901c
MM
2908 ConfigEnvironmentMixin,
2909 MaxlinesEnvironmentMixin,
2910 ):
bc501f69
MH
2911 """Limit the email body to the number of lines specified in config."""
2912
2913 def __init__(self, config, **kw):
2914 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2915 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2916 config=config,
2917 emailmaxlines=emailmaxlines,
2918 **kw
2919 )
2920
2921
b513f71f
MH
2922class FQDNEnvironmentMixin(Environment):
2923 """A mixin that sets the host's FQDN to its constructor argument."""
2924
2925 def __init__(self, fqdn, **kw):
2926 super(FQDNEnvironmentMixin, self).__init__(**kw)
2927 self.COMPUTED_KEYS += ['fqdn']
2928 self.__fqdn = fqdn
2929
2930 def get_fqdn(self):
2931 """Return the fully-qualified domain name for this host.
2932
2933 Return None if it is unavailable or unwanted."""
2934
2935 return self.__fqdn
2936
2937
2938class ConfigFQDNEnvironmentMixin(
5b1d901c
MM
2939 ConfigEnvironmentMixin,
2940 FQDNEnvironmentMixin,
2941 ):
b513f71f
MH
2942 """Read the FQDN from the config."""
2943
2944 def __init__(self, config, **kw):
2945 fqdn = config.get('fqdn')
2946 super(ConfigFQDNEnvironmentMixin, self).__init__(
2947 config=config,
2948 fqdn=fqdn,
2949 **kw
2950 )
2951
2952
2953class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2954 """Get the FQDN by calling socket.getfqdn()."""
2955
2956 def __init__(self, **kw):
2957 super(ComputeFQDNEnvironmentMixin, self).__init__(
2958 fqdn=socket.getfqdn(),
2959 **kw
2960 )
2961
2962
bc501f69
MH
2963class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2964 """Deduce pusher_email from pusher by appending an emaildomain."""
2965
2966 def __init__(self, **kw):
2967 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2968 self.__emaildomain = self.config.get('emaildomain')
2969
2970 def get_pusher_email(self):
2971 if self.__emaildomain:
2972 # Derive the pusher's full email address in the default way:
2973 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2974 else:
2975 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2976
2977
2978class StaticRecipientsEnvironmentMixin(Environment):
2979 """Set recipients statically based on constructor parameters."""
2980
2981 def __init__(
5b1d901c
MM
2982 self,
2983 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2984 **kw
2985 ):
bc501f69
MH
2986 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2987
2988 # The recipients for various types of notification emails, as
2989 # RFC 2822 email addresses separated by commas (or the empty
2990 # string if no recipients are configured). Although there is
2991 # a mechanism to choose the recipient lists based on on the
2992 # actual *contents* of the change being reported, we only
2993 # choose based on the *type* of the change. Therefore we can
2994 # compute them once and for all:
2995 self.__refchange_recipients = refchange_recipients
2996 self.__announce_recipients = announce_recipients
2997 self.__revision_recipients = revision_recipients
2998
7c554311
MM
2999 def check(self):
3000 if not (self.get_refchange_recipients(None) or
3001 self.get_announce_recipients(None) or
3002 self.get_revision_recipients(None) or
3003 self.get_scancommitforcc()):
3004 raise ConfigurationException('No email recipients configured!')
3005 super(StaticRecipientsEnvironmentMixin, self).check()
3006
bc501f69 3007 def get_refchange_recipients(self, refchange):
7c554311
MM
3008 if self.__refchange_recipients is None:
3009 return super(StaticRecipientsEnvironmentMixin,
3010 self).get_refchange_recipients(refchange)
bc501f69
MH
3011 return self.__refchange_recipients
3012
3013 def get_announce_recipients(self, annotated_tag_change):
7c554311
MM
3014 if self.__announce_recipients is None:
3015 return super(StaticRecipientsEnvironmentMixin,
3016 self).get_refchange_recipients(annotated_tag_change)
bc501f69
MH
3017 return self.__announce_recipients
3018
3019 def get_revision_recipients(self, revision):
7c554311
MM
3020 if self.__revision_recipients is None:
3021 return super(StaticRecipientsEnvironmentMixin,
3022 self).get_refchange_recipients(revision)
bc501f69
MH
3023 return self.__revision_recipients
3024
3025
7c554311 3026class CLIRecipientsEnvironmentMixin(Environment):
64127575 3027 """Mixin storing recipients information coming from the
7c554311
MM
3028 command-line."""
3029
3030 def __init__(self, cli_recipients=None, **kw):
3031 super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)
3032 self.__cli_recipients = cli_recipients
3033
3034 def get_refchange_recipients(self, refchange):
3035 if self.__cli_recipients is None:
3036 return super(CLIRecipientsEnvironmentMixin,
3037 self).get_refchange_recipients(refchange)
3038 return self.__cli_recipients
3039
3040 def get_announce_recipients(self, annotated_tag_change):
3041 if self.__cli_recipients is None:
3042 return super(CLIRecipientsEnvironmentMixin,
3043 self).get_announce_recipients(annotated_tag_change)
3044 return self.__cli_recipients
3045
3046 def get_revision_recipients(self, revision):
3047 if self.__cli_recipients is None:
3048 return super(CLIRecipientsEnvironmentMixin,
3049 self).get_revision_recipients(revision)
3050 return self.__cli_recipients
3051
3052
bc501f69 3053class ConfigRecipientsEnvironmentMixin(
5b1d901c
MM
3054 ConfigEnvironmentMixin,
3055 StaticRecipientsEnvironmentMixin
3056 ):
bc501f69
MH
3057 """Determine recipients statically based on config."""
3058
3059 def __init__(self, config, **kw):
3060 super(ConfigRecipientsEnvironmentMixin, self).__init__(
3061 config=config,
3062 refchange_recipients=self._get_recipients(
3063 config, 'refchangelist', 'mailinglist',
3064 ),
3065 announce_recipients=self._get_recipients(
3066 config, 'announcelist', 'refchangelist', 'mailinglist',
3067 ),
3068 revision_recipients=self._get_recipients(
3069 config, 'commitlist', 'mailinglist',
3070 ),
5b1d901c 3071 scancommitforcc=config.get('scancommitforcc'),
bc501f69
MH
3072 **kw
3073 )
3074
3075 def _get_recipients(self, config, *names):
3076 """Return the recipients for a particular type of message.
3077
3078 Return the list of email addresses to which a particular type
3079 of notification email should be sent, by looking at the config
3080 value for "multimailhook.$name" for each of names. Use the
3081 value from the first name that is configured. The return
3082 value is a (possibly empty) string containing RFC 2822 email
3083 addresses separated by commas. If no configuration could be
3084 found, raise a ConfigurationException."""
3085
3086 for name in names:
4b1fd356
MM
3087 lines = config.get_all(name)
3088 if lines is not None:
3089 lines = [line.strip() for line in lines]
3090 # Single "none" is a special value equivalen to empty string.
3091 if lines == ['none']:
3092 lines = ['']
3093 return ', '.join(lines)
bc501f69 3094 else:
b513f71f 3095 return ''
bc501f69
MH
3096
3097
4b1fd356
MM
3098class StaticRefFilterEnvironmentMixin(Environment):
3099 """Set branch filter statically based on constructor parameters."""
3100
3101 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
3102 ref_filter_do_send_regex, ref_filter_dont_send_regex,
3103 **kw):
3104 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
3105
3106 if ref_filter_incl_regex and ref_filter_excl_regex:
3107 raise ConfigurationException(
3108 "Cannot specify both a ref inclusion and exclusion regex.")
3109 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
3110 default_exclude = self.get_default_ref_ignore_regex()
3111 if ref_filter_incl_regex:
3112 ref_filter_regex = ref_filter_incl_regex
3113 elif ref_filter_excl_regex:
3114 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
3115 else:
3116 ref_filter_regex = default_exclude
3117 try:
3118 self.__compiled_regex = re.compile(ref_filter_regex)
3119 except Exception:
3120 raise ConfigurationException(
3121 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
3122
3123 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
3124 raise ConfigurationException(
3125 "Cannot specify both a ref doSend and dontSend regex.")
7c554311
MM
3126 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
3127 if ref_filter_do_send_regex:
3128 ref_filter_send_regex = ref_filter_do_send_regex
3129 elif ref_filter_dont_send_regex:
3130 ref_filter_send_regex = ref_filter_dont_send_regex
4b1fd356 3131 else:
7c554311
MM
3132 ref_filter_send_regex = '.*'
3133 self.__is_do_send_filter = True
3134 try:
3135 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
3136 except Exception:
3137 raise ConfigurationException(
3138 'Invalid Ref Filter Regex "%s": %s' %
3139 (ref_filter_send_regex, sys.exc_info()[1]))
4b1fd356
MM
3140
3141 def get_ref_filter_regex(self, send_filter=False):
3142 if send_filter:
3143 return self.__send_compiled_regex, self.__is_do_send_filter
3144 else:
3145 return self.__compiled_regex, self.__is_inclusion_filter
3146
3147
3148class ConfigRefFilterEnvironmentMixin(
3149 ConfigEnvironmentMixin,
3150 StaticRefFilterEnvironmentMixin
3151 ):
3152 """Determine branch filtering statically based on config."""
3153
3154 def _get_regex(self, config, key):
3155 """Get a list of whitespace-separated regex. The refFilter* config
3156 variables are multivalued (hence the use of get_all), and we
3157 allow each entry to be a whitespace-separated list (hence the
3158 split on each line). The whole thing is glued into a single regex."""
3159 values = config.get_all(key)
3160 if values is None:
3161 return values
3162 items = []
3163 for line in values:
3164 for i in line.split():
3165 items.append(i)
3166 if items == []:
3167 return None
3168 return '|'.join(items)
3169
3170 def __init__(self, config, **kw):
3171 super(ConfigRefFilterEnvironmentMixin, self).__init__(
3172 config=config,
3173 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
3174 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
3175 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
3176 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
3177 **kw
3178 )
3179
3180
bc501f69
MH
3181class ProjectdescEnvironmentMixin(Environment):
3182 """Make a "projectdesc" value available for templates.
3183
3184 By default, it is set to the first line of $GIT_DIR/description
3185 (if that file is present and appears to be set meaningfully)."""
3186
3187 def __init__(self, **kw):
3188 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3189 self.COMPUTED_KEYS += ['projectdesc']
3190
3191 def get_projectdesc(self):
3192 """Return a one-line descripition of the project."""
3193
3194 git_dir = get_git_dir()
3195 try:
3196 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3197 if projectdesc and not projectdesc.startswith('Unnamed repository'):
3198 return projectdesc
3199 except IOError:
3200 pass
3201
3202 return 'UNNAMED PROJECT'
3203
3204
3205class GenericEnvironmentMixin(Environment):
3206 def get_pusher(self):
5b1d901c 3207 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
bc501f69
MH
3208
3209
7c554311
MM
3210class GitoliteEnvironmentHighPrecMixin(Environment):
3211 def get_pusher(self):
3212 return self.osenv.get('GL_USER', 'unknown user')
bc501f69
MH
3213
3214
99177b34
MM
3215class GitoliteEnvironmentLowPrecMixin(
3216 ConfigEnvironmentMixin,
3217 Environment):
3218
bc501f69
MH
3219 def get_repo_shortname(self):
3220 # The gitolite environment variable $GL_REPO is a pretty good
3221 # repo_shortname (though it's probably not as good as a value
3222 # the user might have explicitly put in his config).
3223 return (
4b1fd356 3224 self.osenv.get('GL_REPO', None) or
7c554311 3225 super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()
bc501f69
MH
3226 )
3227
99177b34
MM
3228 @staticmethod
3229 def _compile_regex(re_template):
3230 return (
3231 re.compile(re_template % x)
3232 for x in (
3233 r'BEGIN\s+USER\s+EMAILS',
3234 r'([^\s]+)\s+(.*)',
3235 r'END\s+USER\s+EMAILS',
3236 ))
3237
4b1fd356 3238 def get_fromaddr(self, change=None):
5b1d901c
MM
3239 GL_USER = self.osenv.get('GL_USER')
3240 if GL_USER is not None:
3241 # Find the path to gitolite.conf. Note that gitolite v3
3242 # did away with the GL_ADMINDIR and GL_CONF environment
3243 # variables (they are now hard-coded).
3244 GL_ADMINDIR = self.osenv.get(
3245 'GL_ADMINDIR',
3246 os.path.expanduser(os.path.join('~', '.gitolite')))
3247 GL_CONF = self.osenv.get(
3248 'GL_CONF',
3249 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
99177b34
MM
3250
3251 mailaddress_map = self.config.get('MailaddressMap')
3252 # If relative, consider relative to GL_CONF:
3253 if mailaddress_map:
3254 mailaddress_map = os.path.join(os.path.dirname(GL_CONF),
3255 mailaddress_map)
3256 if os.path.isfile(mailaddress_map):
3257 f = open(mailaddress_map, 'rU')
3258 try:
3259 # Leading '#' is optional
3260 re_begin, re_user, re_end = self._compile_regex(
3261 r'^(?:\s*#)?\s*%s\s*$')
3262 for l in f:
3263 l = l.rstrip('\n')
3264 if re_begin.match(l) or re_end.match(l):
3265 continue # Ignore these lines
3266 m = re_user.match(l)
3267 if m:
3268 if m.group(1) == GL_USER:
3269 return m.group(2)
3270 else:
3271 continue # Not this user, but not an error
3272 raise ConfigurationException(
3273 "Syntax error in mail address map.\n"
3274 "Check file {}.\n"
3275 "Line: {}".format(mailaddress_map, l))
3276
3277 finally:
3278 f.close()
3279
5b1d901c
MM
3280 if os.path.isfile(GL_CONF):
3281 f = open(GL_CONF, 'rU')
3282 try:
3283 in_user_emails_section = False
99177b34
MM
3284 re_begin, re_user, re_end = self._compile_regex(
3285 r'^\s*#\s*%s\s*$')
5b1d901c
MM
3286 for l in f:
3287 l = l.rstrip('\n')
3288 if not in_user_emails_section:
3289 if re_begin.match(l):
3290 in_user_emails_section = True
3291 continue
3292 if re_end.match(l):
3293 break
3294 m = re_user.match(l)
99177b34
MM
3295 if m and m.group(1) == GL_USER:
3296 return m.group(2)
5b1d901c
MM
3297 finally:
3298 f.close()
7c554311 3299 return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)
5b1d901c 3300
bc501f69 3301
b513f71f
MH
3302class IncrementalDateTime(object):
3303 """Simple wrapper to give incremental date/times.
3304
3305 Each call will result in a date/time a second later than the
3306 previous call. This can be used to falsify email headers, to
3307 increase the likelihood that email clients sort the emails
3308 correctly."""
3309
3310 def __init__(self):
3311 self.time = time.time()
4b1fd356 3312 self.next = self.__next__ # Python 2 backward compatibility
b513f71f 3313
4b1fd356 3314 def __next__(self):
b513f71f
MH
3315 formatted = formatdate(self.time, True)
3316 self.time += 1
3317 return formatted
3318
3319
7c554311 3320class StashEnvironmentHighPrecMixin(Environment):
4b1fd356 3321 def __init__(self, user=None, repo=None, **kw):
7c554311
MM
3322 super(StashEnvironmentHighPrecMixin,
3323 self).__init__(user=user, repo=repo, **kw)
4b1fd356
MM
3324 self.__user = user
3325 self.__repo = repo
3326
4b1fd356 3327 def get_pusher(self):
99177b34 3328 return re.match(r'(.*?)\s*<', self.__user).group(1)
4b1fd356
MM
3329
3330 def get_pusher_email(self):
3331 return self.__user
3332
4b1fd356 3333
7c554311
MM
3334class StashEnvironmentLowPrecMixin(Environment):
3335 def __init__(self, user=None, repo=None, **kw):
3336 super(StashEnvironmentLowPrecMixin, self).__init__(**kw)
3337 self.__repo = repo
3338 self.__user = user
4b1fd356 3339
7c554311
MM
3340 def get_repo_shortname(self):
3341 return self.__repo
3342
3343 def get_fromaddr(self, change=None):
3344 return self.__user
4b1fd356
MM
3345
3346
7c554311 3347class GerritEnvironmentHighPrecMixin(Environment):
4b1fd356 3348 def __init__(self, project=None, submitter=None, update_method=None, **kw):
7c554311
MM
3349 super(GerritEnvironmentHighPrecMixin,
3350 self).__init__(submitter=submitter, project=project, **kw)
4b1fd356
MM
3351 self.__project = project
3352 self.__submitter = submitter
3353 self.__update_method = update_method
3354 "Make an 'update_method' value available for templates."
3355 self.COMPUTED_KEYS += ['update_method']
3356
4b1fd356
MM
3357 def get_pusher(self):
3358 if self.__submitter:
3359 if self.__submitter.find('<') != -1:
3360 # Submitter has a configured email, we transformed
3361 # __submitter into an RFC 2822 string already.
99177b34 3362 return re.match(r'(.*?)\s*<', self.__submitter).group(1)
4b1fd356
MM
3363 else:
3364 # Submitter has no configured email, it's just his name.