1#! /usr/bin/env python 2 3__version__ = '1.5.0' 4 5# Copyright (c) 2015-2016 Matthieu Moy and others 6# Copyright (c) 2012-2014 Michael Haggerty and others 7# Derived from contrib/hooks/post-receive-email, which is 8# Copyright (c) 2007 Andy Parkins 9# and also includes contributions by other authors. 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 55import socket 56import subprocess 57import shlex 58import optparse 59import logging 60import smtplib 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 66import time 67 68import uuid 69import base64 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 78 return True 79 80 81def is_ascii(s): 82 return all(ord(c) < 128 and ord(c) > 0 for c in s) 83 84 85if PYTHON3: 86 def is_string(s): 87 return isinstance(s, str) 88 89 def str_to_bytes(s): 90 return s.encode(ENCODING) 91 92 def bytes_to_str(s, errors='strict'): 93 return s.decode(ENCODING, errors) 94 95 unicode = str 96 97 def write_str(f, msg): 98 # Try outputting 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)) 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) 113 114 import html 115 116 def html_escape(s): 117 return html.escape(s) 118 119else: 120 def is_string(s): 121 try: 122 return isinstance(s, basestring) 123 except NameError: # Silence Pyflakes warning 124 raise 125 126 def str_to_bytes(s): 127 return s 128 129 def bytes_to_str(s, errors='strict'): 130 return s 131 132 def write_str(f, msg): 133 f.write(msg) 134 135 def read_line(f): 136 return f.readline() 137 138 def next(it): 139 return it.next() 140 141 import cgi 142 143 def html_escape(s): 144 return cgi.escape(s, True) 145 146try: 147 from email.charset import Charset 148 from email.utils import make_msgid 149 from email.utils import getaddresses 150 from email.utils import formataddr 151 from email.utils import formatdate 152 from email.header import Header 153except ImportError: 154 # Prior to Python 2.5, the email module used different names: 155 from email.Charset import Charset 156 from email.Utils import make_msgid 157 from email.Utils import getaddresses 158 from email.Utils import formataddr 159 from email.Utils import formatdate 160 from email.Header import Header 161 162 163DEBUG = False 164 165ZEROS = '0' * 40 166LOGBEGIN = '- Log -----------------------------------------------------------------\n' 167LOGEND = '-----------------------------------------------------------------------\n' 168 169ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender']) 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 191COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( 192 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s' 193 ) 194 195REFCHANGE_HEADER_TEMPLATE = """\ 196Date: %(send_date)s 197To: %(recipients)s 198Subject: %(subject)s 199MIME-Version: 1.0 200Content-Type: text/%(contenttype)s; charset=%(charset)s 201Content-Transfer-Encoding: 8bit 202Message-ID: %(msgid)s 203From: %(fromaddr)s 204Reply-To: %(reply_to)s 205Thread-Index: %(thread_index)s 206X-Git-Host: %(fqdn)s 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 212X-Git-NotificationType: ref_changed 213X-Git-Multimail-Version: %(multimail_version)s 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 242Any revisions marked "omit" are not gone; other references still 243refer to them. Any revisions marked "discard" are gone forever. 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 262Any revisions marked "omit" are not gone; other references still 263refer to them. Any revisions marked "discard" are gone forever. 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 287listed as "add" were already present in the repository and have only 288been added to this reference. 289 290""" 291 292 293TAG_CREATED_TEMPLATE = """\ 294 at %(newrev_short)-8s (%(newrev_type)s) 295""" 296 297 298TAG_UPDATED_TEMPLATE = """\ 299*** WARNING: tag %(short_refname)s was modified! *** 300 301 from %(oldrev_short)-8s (%(oldrev_type)s) 302 to %(newrev_short)-8s (%(newrev_type)s) 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 = """\ 315%(action)8s %(rev_short)-8s %(text)s 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 = """\ 327Date: %(send_date)s 328To: %(recipients)s 329Cc: %(cc_recipients)s 330Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s 331MIME-Version: 1.0 332Content-Type: text/%(contenttype)s; charset=%(charset)s 333Content-Transfer-Encoding: 8bit 334From: %(fromaddr)s 335Reply-To: %(reply_to)s 336In-Reply-To: %(reply_to_msgid)s 337References: %(reply_to_msgid)s 338Thread-Index: %(thread_index)s 339X-Git-Host: %(fqdn)s 340X-Git-Repo: %(repo_shortname)s 341X-Git-Refname: %(refname)s 342X-Git-Reftype: %(refname_type)s 343X-Git-Rev: %(rev)s 344X-Git-NotificationType: diff 345X-Git-Multimail-Version: %(multimail_version)s 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 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 367 368REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 369 370 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 377Content-Type: text/%(contenttype)s; charset=%(charset)s 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 389X-Git-NotificationType: ref_changed_plus_diff 390X-Git-Multimail-Version: %(multimail_version)s 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 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 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 449def read_git_output(args, input=None, keepends=False, **kw): 450 """Read the output of a Git command.""" 451 452 if GIT_CMD is None: 453 choose_git_command() 454 455 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw) 456 457 458def read_output(cmd, input=None, keepends=False, **kw): 459 if input: 460 stdin = subprocess.PIPE 461 input = str_to_bytes(input) 462 else: 463 stdin = None 464 errors = 'strict' 465 if 'errors' in kw: 466 errors = kw['errors'] 467 del kw['errors'] 468 p = subprocess.Popen( 469 tuple(str_to_bytes(w) for w in cmd), 470 stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 471 ) 472 (out, err) = p.communicate(input) 473 out = bytes_to_str(out, errors=errors) 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 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 531def header_encode(text, header_name=None): 532 """Encode and line-wrap the value of an email header field.""" 533 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() 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 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() 565 566 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 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 613 def get(self, name, default=None): 614 try: 615 values = self._split(read_git_output( 616 ['config', '--get', '--null', '%s.%s' % (self.section, name)], 617 env=self.env, keepends=True, 618 )) 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 )) 645 except CommandError: 646 t, e, traceback = sys.exc_info() 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 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 666 def __contains__(self, name): 667 return self.get_all(name, default=None) is not None 668 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 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 ) 680 except CommandError: 681 t, e, traceback = sys.exc_info() 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 775 return next(iter(generate_summaries('--no-walk', self.sha1))) 776 777 def __eq__(self, other): 778 return isinstance(other, GitObject) and self.sha1 == other.sha1 779 780 def __ne__(self, other): 781 return not self == other 782 783 def __hash__(self): 784 return hash(self.sha1) 785 786 def __nonzero__(self): 787 return bool(self.sha1) 788 789 def __bool__(self): 790 """Python 2 backward compatibility""" 791 return self.__nonzero__() 792 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 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 813 814 def _compute_values(self): 815 """Return a dictionary {keyword: expansion} for this Change. 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 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 828 829 # Aliases usable in template strings. Tuple of pairs (destination, 830 # source). 831 VALUES_ALIAS = ( 832 ("id", "newrev"), 833 ) 834 835 def get_values(self, **extra_values): 836 """Return a dictionary {keyword: expansion} for this Change. 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) 850 851 for alias, val in self.VALUES_ALIAS: 852 values[alias] = values[val] 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 865 def expand_lines(self, template, html_escape_val=False, **extra_values): 866 """Break template into lines and expand each line.""" 867 868 values = self.get_values(**extra_values) 869 if html_escape_val: 870 for k in values: 871 if is_string(values[k]): 872 values[k] = html_escape(values[k]) 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) 883 if self._contains_html_diff: 884 self._content_type = 'html' 885 else: 886 self._content_type = 'plain' 887 values['contenttype'] = self._content_type 888 889 for line in template.splitlines(): 890 (name, value) = line.split(': ', 1) 891 892 try: 893 value = value % values 894 except KeyError: 895 t, e, traceback = sys.exc_info() 896 if DEBUG: 897 self.environment.log_warning( 898 'Warning: unknown variable %r in the following line; line skipped:\n' 899 ' %s\n' 900 % (e.args[0], line,) 901 ) 902 else: 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): 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 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): 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 929 def generate_email_body(self, push): 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 937 def generate_email_footer(self, html_escape_val): 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 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: 956 yield html_escape(line) 957 958 yield '</pre>\n' 959 else: 960 for line in lines: 961 yield line 962 963 def generate_email(self, push, body_filter=None, extra_header_values={}): 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 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()""" 974 975 for line in self.generate_email_header(**extra_header_values): 976 yield line 977 yield '\n' 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: 984 yield line 985 986 if self.environment.commitBrowseURL: 987 for line in self.generate_browse_link(self.environment.commitBrowseURL): 988 yield line 989 990 body = self.generate_email_body(push) 991 if body_filter is not None: 992 body = body_filter(body) 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">' 1002 for line in body: 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>. 1031 line = html_escape(line[:-1]) 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 1047 yield line 1048 if self._contains_html_diff: 1049 yield '</pre>' 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: 1056 yield line 1057 1058 def get_specific_fromaddr(self): 1059 """For kinds of Changes which specify it, return the kind-specific 1060 From address to use.""" 1061 return None 1062 1063 1064class Revision(Change): 1065 """A Change consisting of a single git commit.""" 1066 1067 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$') 1068 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 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 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( 1089 'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1)) 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 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 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 1113 values['rev'] = self.rev.sha1 1114 values['parents'] = ' '.join(self.parents) 1115 values['rev_short'] = self.rev.short 1116 values['change_type'] = self.change_type 1117 values['refname'] = self.refname 1118 values['newrev'] = self.rev.sha1 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 1122 values['thread_index'] = self.reference_change.thread_index 1123 values['num'] = self.num 1124 values['tot'] = self.tot 1125 values['recipients'] = self.recipients 1126 if self.cc_recipients: 1127 values['cc_recipients'] = self.cc_recipients 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 1137 def generate_email_header(self, **extra_values): 1138 for line in self.expand_header_lines( 1139 REVISION_HEADER_TEMPLATE, **extra_values 1140 ): 1141 yield line 1142 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): 1163 yield line 1164 1165 def generate_email_body(self, push): 1166 """Show this revision.""" 1167 1168 for line in read_git_lines( 1169 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1], 1170 keepends=True, 1171 errors='replace'): 1172 if line.startswith('Date: ') and self.environment.date_substitute: 1173 yield self.environment.date_substitute + line[len('Date: '):] 1174 else: 1175 yield line 1176 1177 def generate_email_footer(self, html_escape_val): 1178 return self.expand_lines(REVISION_FOOTER_TEMPLATE, 1179 html_escape_val=html_escape_val) 1180 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 1185 def get_specific_fromaddr(self): 1186 return self.environment.from_commit 1187 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: 1241 environment.log_warning( 1242 '*** Push-update of tracking branch %r\n' 1243 '*** - incomplete email generated.' 1244 % (refname,) 1245 ) 1246 klass = OtherReferenceChange 1247 else: 1248 # Some other reference namespace: 1249 environment.log_warning( 1250 '*** Push-update of strange reference %r\n' 1251 '*** - incomplete email generated.' 1252 % (refname,) 1253 ) 1254 klass = OtherReferenceChange 1255 else: 1256 # Anything else (is there anything else?) 1257 environment.log_warning( 1258 '*** Unknown type of update to %r (%s)\n' 1259 '*** - incomplete email generated.' 1260 % (refname, rev.type,) 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 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 1287 def __init__(self, environment, refname, short_refname, old, new, rev): 1288 Change.__init__(self, environment) 1289 self.change_type = { 1290 (False, True): 'create', 1291 (True, True): 'update', 1292 (True, False): 'delete', 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() 1300 self.thread_index = self.make_thread_index() 1301 self.diffopts = environment.diffopts 1302 self.graphopts = environment.graphopts 1303 self.logopts = environment.logopts 1304 self.commitlogopts = environment.commitlogopts 1305 self.showgraph = environment.refchange_showgraph 1306 self.showlog = environment.refchange_showlog 1307 1308 self.header_template = REFCHANGE_HEADER_TEMPLATE 1309 self.intro_template = REFCHANGE_INTRO_TEMPLATE 1310 self.footer_template = FOOTER_TEMPLATE 1311 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 1320 values['thread_index'] = self.thread_index 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 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 1366 def get_subject(self): 1367 template = { 1368 'create': REF_CREATED_SUBJECT_TEMPLATE, 1369 'update': REF_UPDATED_SUBJECT_TEMPLATE, 1370 'delete': REF_DELETED_SUBJECT_TEMPLATE, 1371 }[self.change_type] 1372 return self.expand(template) 1373 1374 def generate_email_header(self, **extra_values): 1375 if 'subject' not in extra_values: 1376 extra_values['subject'] = self.get_subject() 1377 1378 for line in self.expand_header_lines( 1379 self.header_template, **extra_values 1380 ): 1381 yield line 1382 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): 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 = { 1395 'create': self.generate_create_summary, 1396 'delete': self.generate_delete_summary, 1397 'update': self.generate_update_summary, 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 1405 def generate_email_footer(self, html_escape_val): 1406 return self.expand_lines(self.footer_template, 1407 html_escape_val=html_escape_val) 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' 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( 1430 ['log', '--no-walk'] + 1431 self.logopts + 1432 new_commits_list + 1433 ['--'], 1434 keepends=True, 1435 ): 1436 yield line 1437 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 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 = [ 1458 Revision(self, GitObject(sha1), num=i + 1, tot=tot) 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' 1471 for line in self.generate_new_revision_summary( 1472 tot, [r.rev.sha1 for r in new_revisions], push): 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( 1490 '--topo-order', '--reverse', '%s..%s' 1491 % (self.old.commit_sha1, self.new.commit_sha1,) 1492 )) 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( 1498 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,) 1499 )) 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: 1515 action = 'discard' 1516 else: 1517 action = 'omit' 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: 1526 action = 'add' 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: 1538 action = 'discard' 1539 else: 1540 action = 'omit' 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: 1559 action = 'add' 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: 1568 for line in self.generate_new_revision_summary( 1569 len(new_commits), new_commits_list, push): 1570 yield line 1571 else: 1572 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): 1573 yield line 1574 for line in self.generate_revision_change_graph(push): 1575 yield line 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( 1587 ['diff-tree'] + 1588 self.diffopts + 1589 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)], 1590 keepends=True, 1591 ): 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 = [ 1601 Revision(self, GitObject(sha1), num=i + 1, tot=tot) 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( 1612 BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject, 1613 ) 1614 for line in self.generate_revision_change_graph(push): 1615 yield line 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 1650 def get_specific_fromaddr(self): 1651 return self.environment.from_refchange 1652 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) 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 ( 1734 len(new_commits) == 1 and 1735 len(new_commits[0][1]) == 1 and 1736 new_commits[0][0] in known_added_sha1s 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 1769 self._contains_diff() 1770 self.header_template = COMBINED_HEADER_TEMPLATE 1771 self.intro_template = COMBINED_INTRO_TEMPLATE 1772 self.footer_template = COMBINED_FOOTER_TEMPLATE 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 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 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: 1860 yield ' replaces %s\n' % (prevtag,) 1861 else: 1862 prevtag = None 1863 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),) 1864 1865 yield ' by %s\n' % (tagger,) 1866 yield ' on %s\n' % (tagged,) 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 1983 def __init__(self, environment): 1984 self.environment = environment 1985 1986 def close(self): 1987 pass 1988 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): 2005 """Send emails using 'sendmail -oi -t'.""" 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 2023 def __init__(self, environment, command=None, envelopesender=None): 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'.""" 2030 super(SendMailer, self).__init__(environment) 2031 if command: 2032 self.command = command[:] 2033 else: 2034 self.command = [self.find_sendmail(), '-oi', '-t'] 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) 2042 except OSError: 2043 self.environment.get_logger().error( 2044 '*** Cannot execute command: %s\n' % ' '.join(self.command) + 2045 '*** %s\n' % sys.exc_info()[1] + 2046 '*** Try setting multimailhook.mailer to "smtp"\n' + 2047 '*** to send emails without using the sendmail command.\n' 2048 ) 2049 sys.exit(1) 2050 try: 2051 lines = (str_to_bytes(line) for line in lines) 2052 p.stdin.writelines(lines) 2053 except Exception: 2054 self.environment.get_logger().error( 2055 '*** Error while generating commit email\n' 2056 '*** - mail sending aborted.\n' 2057 ) 2058 if hasattr(p, 'terminate'): 2059 # subprocess.terminate() is not available in Python 2.4 2060 p.terminate() 2061 else: 2062 import signal 2063 os.kill(p.pid, signal.SIGTERM) 2064 raise 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 2075 def __init__(self, environment, 2076 envelopesender, smtpserver, 2077 smtpservertimeout=10.0, smtpserverdebuglevel=0, 2078 smtpencryption='none', 2079 smtpuser='', smtppass='', 2080 smtpcacerts='' 2081 ): 2082 super(SMTPMailer, self).__init__(environment) 2083 if not envelopesender: 2084 self.environment.get_logger().error( 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) 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 ) 2094 self.envelopesender = envelopesender 2095 self.smtpserver = smtpserver 2096 self.smtpservertimeout = smtpservertimeout 2097 self.smtpserverdebuglevel = smtpserverdebuglevel 2098 self.security = smtpencryption 2099 self.username = smtpuser 2100 self.password = smtppass 2101 self.smtpcacerts = smtpcacerts 2102 self.loggedin = False 2103 try: 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) 2110 if self.security == 'none': 2111 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) 2112 elif self.security == 'ssl': 2113 if self.smtpcacerts: 2114 raise smtplib.SMTPException( 2115 "Checking certificate is not supported for ssl, prefer starttls" 2116 ) 2117 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout) 2118 elif self.security == 'tls': 2119 if 'ssl' not in sys.modules: 2120 self.environment.get_logger().error( 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') 2125 if ':' not in self.smtpserver: 2126 self.smtpserver += ':587' # default port for TLS 2127 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) 2128 # start: ehlo + starttls 2129 # equivalent to 2130 # self.smtp.ehlo() 2131 # self.smtp.starttls() 2132 # with access to the ssl layer 2133 self.smtp.ehlo() 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 ) 2150 self.environment.get_logger().error( 2151 '*** Warning, the server certificate 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 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) 2172 except Exception: 2173 self.environment.get_logger().error( 2174 '*** Error establishing SMTP connection to %s ***\n' 2175 '*** %s\n' 2176 % (self.smtpserver, sys.exc_info()[1])) 2177 sys.exit(1) 2178 2179 def close(self): 2180 if hasattr(self, 'smtp'): 2181 self.smtp.quit() 2182 del self.smtp 2183 2184 def __del__(self): 2185 self.close() 2186 2187 def send(self, lines, to_addrs): 2188 try: 2189 if self.username or self.password: 2190 if not self.loggedin: 2191 self.smtp.login(self.username, self.password) 2192 self.loggedin = True 2193 msg = ''.join(lines) 2194 # turn comma-separated list into Python list if needed. 2195 if is_string(to_addrs): 2196 to_addrs = [email for (name, email) in getaddresses([to_addrs])] 2197 self.smtp.sendmail(self.envelopesender, to_addrs, msg) 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) 2203 except smtplib.SMTPResponseException: 2204 err = sys.exc_info()[1] 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))) 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: 2216 self.environment.get_logger().error( 2217 '*** Error closing the SMTP connection ***\n' 2218 '*** Exiting anyway ... ***\n' 2219 '*** %s\n' % sys.exc_info()[1]) 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 2230 def __init__(self, f, environment=None): 2231 super(OutputMailer, self).__init__(environment=environment) 2232 self.f = f 2233 2234 def send(self, lines, to_addrs): 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) 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 2304 get_fromaddr(change=None) 2305 2306 Return the 'From' email address used in the email 'From:' 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>'.) 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 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 2342 get_max_subject_length() 2343 2344 Return an int giving the maximal length for the subject 2345 (git log --oneline). 2346 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 2353 commit_email_format (string) 2354 2355 If "html", generate commit emails in HTML instead of plain text 2356 used by default. 2357 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 2366 refchange_showgraph (bool) 2367 2368 True iff refchanges emails should include a detailed graph. 2369 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 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 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 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 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 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 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 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 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 2437 self.commit_email_format = "text" 2438 self.html_in_intro = False 2439 self.html_in_footer = False 2440 self.commitBrowseURL = None 2441 self.maxcommitemails = 500 2442 self.excludemergerevisions = False 2443 self.diffopts = ['--stat', '--summary', '--find-copies-harder'] 2444 self.graphopts = ['--oneline', '--decorate'] 2445 self.logopts = [] 2446 self.refchange_showgraph = False 2447 self.refchange_showlog = False 2448 self.commitlogopts = ['-C', '--stat', '-p', '--cc'] 2449 self.date_substitute = 'AuthorDate: ' 2450 self.quiet = False 2451 self.stdout = False 2452 self.combine_when_single_commit = True 2453 self.logger = None 2454 2455 self.COMPUTED_KEYS = [ 2456 'administrator', 2457 'charset', 2458 'emailprefix', 2459 'pusher', 2460 'pusher_email', 2461 'repo_path', 2462 'repo_shortname', 2463 'sender', 2464 ] 2465 2466 self._values = None 2467 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 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 2490 def get_fromaddr(self, change=None): 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 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): 2515 """Return a dictionary {keyword: expansion} for this Environment. 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: 2525 values = {'': ''} # %()s expands to the empty string. 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 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 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 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 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.""" 2601 self.get_logger().info(msg) 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.""" 2607 self.get_logger().warning(msg) 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.""" 2613 self.get_logger().error(msg) 2614 2615 def check(self): 2616 pass 2617 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 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 2645 def __init__(self, config, **kw): 2646 super(ConfigOptionsEnvironmentMixin, self).__init__( 2647 config=config, **kw 2648 ) 2649 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) 2660 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 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 2682 self.excludemergerevisions = config.get('excludeMergeRevisions') 2683 2684 maxcommitemails = config.get('maxcommitemails') 2685 if maxcommitemails is not None: 2686 try: 2687 self.maxcommitemails = int(maxcommitemails) 2688 except ValueError: 2689 self.log_warning( 2690 '*** Malformed value for multimailhook.maxCommitEmails: %s\n' 2691 % maxcommitemails + 2692 '*** Expected a number. Ignoring.\n' 2693 ) 2694 2695 diffopts = config.get('diffopts') 2696 if diffopts is not None: 2697 self.diffopts = shlex.split(diffopts) 2698 2699 graphopts = config.get('graphOpts') 2700 if graphopts is not None: 2701 self.graphopts = shlex.split(graphopts) 2702 2703 logopts = config.get('logopts') 2704 if logopts is not None: 2705 self.logopts = shlex.split(logopts) 2706 2707 commitlogopts = config.get('commitlogopts') 2708 if commitlogopts is not None: 2709 self.commitlogopts = shlex.split(commitlogopts) 2710 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 2717 reply_to = config.get('replyTo') 2718 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to) 2719 self.forbid_field_values('replyToRefchange', 2720 self.__reply_to_refchange, 2721 ['author']) 2722 self.__reply_to_commit = config.get('replyToCommit', default=reply_to) 2723 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 2733 combine = config.get_bool('combineWhenSingleCommit') 2734 if combine is not None: 2735 self.combine_when_single_commit = combine 2736 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 2745 def get_administrator(self): 2746 return ( 2747 self.config.get('administrator') or 2748 self.get_sender() or 2749 super(ConfigOptionsEnvironmentMixin, self).get_administrator() 2750 ) 2751 2752 def get_repo_shortname(self): 2753 return ( 2754 self.config.get('reponame') or 2755 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname() 2756 ) 2757 2758 def get_emailprefix(self): 2759 emailprefix = self.config.get('emailprefix') 2760 if emailprefix is not None: 2761 emailprefix = emailprefix.strip() 2762 if emailprefix: 2763 emailprefix += ' ' 2764 else: 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 ) 2778 2779 def get_sender(self): 2780 return self.config.get('envelopesender') 2781 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): 2796 fromaddr = self.config.get('from') 2797 if change: 2798 specific_fromaddr = change.get_specific_fromaddr() 2799 if specific_fromaddr: 2800 fromaddr = specific_fromaddr 2801 if fromaddr: 2802 fromaddr = self.process_addr(fromaddr, change) 2803 if fromaddr: 2804 return fromaddr 2805 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change) 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) 2810 else: 2811 return self.process_addr(self.__reply_to_refchange, refchange) 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) 2816 else: 2817 return self.process_addr(self.__reply_to_commit, revision) 2818 2819 def get_scancommitforcc(self): 2820 return self.config.get('scancommitforcc') 2821 2822 2823class FilterLinesEnvironmentMixin(Environment): 2824 """Handle encoding and maximum line length of body lines. 2825 2826 email_max_line_length (int or None) 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 2841 def __init__(self, strict_utf8=True, 2842 email_max_line_length=500, max_subject_length=500, 2843 **kw): 2844 super(FilterLinesEnvironmentMixin, self).__init__(**kw) 2845 self.__strict_utf8 = strict_utf8 2846 self.__email_max_line_length = email_max_line_length 2847 self.__max_subject_length = max_subject_length 2848 2849 def filter_body(self, lines): 2850 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines) 2851 if self.__strict_utf8: 2852 if not PYTHON3: 2853 lines = (line.decode(ENCODING, 'replace') for line in lines) 2854 # Limit the line length in Unicode-space to avoid 2855 # splitting characters: 2856 if self.__email_max_line_length > 0: 2857 lines = limit_linelength(lines, self.__email_max_line_length) 2858 if not PYTHON3: 2859 lines = (line.encode(ENCODING, 'replace') for line in lines) 2860 elif self.__email_max_line_length: 2861 lines = limit_linelength(lines, self.__email_max_line_length) 2862 2863 return lines 2864 2865 def get_max_subject_length(self): 2866 return self.__max_subject_length 2867 2868 2869class ConfigFilterLinesEnvironmentMixin( 2870 ConfigEnvironmentMixin, 2871 FilterLinesEnvironmentMixin, 2872 ): 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 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) 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) 2902 if self.__emailmaxlines > 0: 2903 lines = limit_lines(lines, self.__emailmaxlines) 2904 return lines 2905 2906 2907class ConfigMaxlinesEnvironmentMixin( 2908 ConfigEnvironmentMixin, 2909 MaxlinesEnvironmentMixin, 2910 ): 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 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( 2939 ConfigEnvironmentMixin, 2940 FQDNEnvironmentMixin, 2941 ): 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 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__( 2982 self, 2983 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc, 2984 **kw 2985 ): 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 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 3007 def get_refchange_recipients(self, refchange): 3008 if self.__refchange_recipients is None: 3009 return super(StaticRecipientsEnvironmentMixin, 3010 self).get_refchange_recipients(refchange) 3011 return self.__refchange_recipients 3012 3013 def get_announce_recipients(self, annotated_tag_change): 3014 if self.__announce_recipients is None: 3015 return super(StaticRecipientsEnvironmentMixin, 3016 self).get_refchange_recipients(annotated_tag_change) 3017 return self.__announce_recipients 3018 3019 def get_revision_recipients(self, revision): 3020 if self.__revision_recipients is None: 3021 return super(StaticRecipientsEnvironmentMixin, 3022 self).get_refchange_recipients(revision) 3023 return self.__revision_recipients 3024 3025 3026class CLIRecipientsEnvironmentMixin(Environment): 3027 """Mixin storing recipients information coming from the 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 3053class ConfigRecipientsEnvironmentMixin( 3054 ConfigEnvironmentMixin, 3055 StaticRecipientsEnvironmentMixin 3056 ): 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 ), 3071 scancommitforcc=config.get('scancommitforcc'), 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: 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) 3094 else: 3095 return '' 3096 3097 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.") 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 3131 else: 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])) 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 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 description 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): 3207 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user')) 3208 3209 3210class GitoliteEnvironmentHighPrecMixin(Environment): 3211 def get_pusher(self): 3212 return self.osenv.get('GL_USER', 'unknown user') 3213 3214 3215class GitoliteEnvironmentLowPrecMixin( 3216 ConfigEnvironmentMixin, 3217 Environment): 3218 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 ( 3224 self.osenv.get('GL_REPO', None) or 3225 super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname() 3226 ) 3227 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 3238 def get_fromaddr(self, change=None): 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')) 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 3280 if os.path.isfile(GL_CONF): 3281 f = open(GL_CONF, 'rU') 3282 try: 3283 in_user_emails_section = False 3284 re_begin, re_user, re_end = self._compile_regex( 3285 r'^\s*#\s*%s\s*$') 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) 3295 if m and m.group(1) == GL_USER: 3296 return m.group(2) 3297 finally: 3298 f.close() 3299 return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change) 3300 3301 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() 3312 self.next = self.__next__ # Python 2 backward compatibility 3313 3314 def __next__(self): 3315 formatted = formatdate(self.time, True) 3316 self.time += 1 3317 return formatted 3318 3319 3320class StashEnvironmentHighPrecMixin(Environment): 3321 def __init__(self, user=None, repo=None, **kw): 3322 super(StashEnvironmentHighPrecMixin, 3323 self).__init__(user=user, repo=repo, **kw) 3324 self.__user = user 3325 self.__repo = repo 3326 3327 def get_pusher(self): 3328 return re.match(r'(.*?)\s*<', self.__user).group(1) 3329 3330 def get_pusher_email(self): 3331 return self.__user 3332 3333 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 3339 3340 def get_repo_shortname(self): 3341 return self.__repo 3342 3343 def get_fromaddr(self, change=None): 3344 return self.__user 3345 3346 3347class GerritEnvironmentHighPrecMixin(Environment): 3348 def __init__(self, project=None, submitter=None, update_method=None, **kw): 3349 super(GerritEnvironmentHighPrecMixin, 3350 self).__init__(submitter=submitter, project=project, **kw) 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 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. 3362 return re.match(r'(.*?)\s*<', self.__submitter).group(1) 3363 else: 3364 # Submitter has no configured email, it's just his name. 3365 return self.__submitter 3366 else: 3367 # If we arrive here, this means someone pushed "Submit" from 3368 # the gerrit web UI for the CR (or used one of the programmatic 3369 # APIs to do the same, such as gerrit review) and the 3370 # merge/push was done by the Gerrit user. It was technically 3371 # triggered by someone else, but sadly we have no way of 3372 # determining who that someone else is at this point. 3373 return 'Gerrit' # 'unknown user'? 3374 3375 def get_pusher_email(self): 3376 if self.__submitter: 3377 return self.__submitter 3378 else: 3379 return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email() 3380 3381 def get_default_ref_ignore_regex(self): 3382 default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex() 3383 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/' 3384 3385 def get_revision_recipients(self, revision): 3386 # Merge commits created by Gerrit when users hit "Submit this patchset" 3387 # in the Web UI (or do equivalently with REST APIs or the gerrit review 3388 # command) are not something users want to see an individual email for. 3389 # Filter them out. 3390 committer = read_git_output(['log', '--no-walk', '--format=%cN', 3391 revision.rev.sha1]) 3392 if committer == 'Gerrit Code Review': 3393 return [] 3394 else: 3395 return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision) 3396 3397 def get_update_method(self): 3398 return self.__update_method 3399 3400 3401class GerritEnvironmentLowPrecMixin(Environment): 3402 def __init__(self, project=None, submitter=None, **kw): 3403 super(GerritEnvironmentLowPrecMixin, self).__init__(**kw) 3404 self.__project = project 3405 self.__submitter = submitter 3406 3407 def get_repo_shortname(self): 3408 return self.__project 3409 3410 def get_fromaddr(self, change=None): 3411 if self.__submitter and self.__submitter.find('<') != -1: 3412 return self.__submitter 3413 else: 3414 return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change) 3415 3416 3417class Push(object): 3418 """Represent an entire push (i.e., a group of ReferenceChanges). 3419 3420 It is easy to figure out what commits were added to a *branch* by 3421 a Reference change: 3422 3423 git rev-list change.old..change.new 3424 3425 or removed from a *branch*: 3426 3427 git rev-list change.new..change.old 3428 3429 But it is not quite so trivial to determine which entirely new 3430 commits were added to the *repository* by a push and which old 3431 commits were discarded by a push. A big part of the job of this 3432 class is to figure out these things, and to make sure that new 3433 commits are only detailed once even if they were added to multiple 3434 references. 3435 3436 The first step is to determine the "other" references--those 3437 unaffected by the current push. They are computed by listing all 3438 references then removing any affected by this push. The results 3439 are stored in Push._other_ref_sha1s. 3440 3441 The commits contained in the repository before this push were 3442 3443 git rev-list other1 other2 other3 ... change1.old change2.old ... 3444 3445 Where "changeN.old" is the old value of one of the references 3446 affected by this push. 3447 3448 The commits contained in the repository after this push are 3449 3450 git rev-list other1 other2 other3 ... change1.new change2.new ... 3451 3452 The commits added by this push are the difference between these 3453 two sets, which can be written 3454 3455 git rev-list \ 3456 ^other1 ^other2 ... \ 3457 ^change1.old ^change2.old ... \ 3458 change1.new change2.new ... 3459 3460 The commits removed by this push can be computed by 3461 3462 git rev-list \ 3463 ^other1 ^other2 ... \ 3464 ^change1.new ^change2.new ... \ 3465 change1.old change2.old ... 3466 3467 The last point is that it is possible that other pushes are 3468 occurring simultaneously to this one, so reference values can 3469 change at any time. It is impossible to eliminate all race 3470 conditions, but we reduce the window of time during which problems 3471 can occur by translating reference names to SHA1s as soon as 3472 possible and working with SHA1s thereafter (because SHA1s are 3473 immutable).""" 3474 3475 # A map {(changeclass, changetype): integer} specifying the order 3476 # that reference changes will be processed if multiple reference 3477 # changes are included in a single push. The order is significant 3478 # mostly because new commit notifications are threaded together 3479 # with the first reference change that includes the commit. The 3480 # following order thus causes commits to be grouped with branch 3481 # changes (as opposed to tag changes) if possible. 3482 SORT_ORDER = dict( 3483 (value, i) for (i, value) in enumerate([ 3484 (BranchChange, 'update'), 3485 (BranchChange, 'create'), 3486 (AnnotatedTagChange, 'update'), 3487 (AnnotatedTagChange, 'create'), 3488 (NonAnnotatedTagChange, 'update'), 3489 (NonAnnotatedTagChange, 'create'), 3490 (BranchChange, 'delete'), 3491 (AnnotatedTagChange, 'delete'), 3492 (NonAnnotatedTagChange, 'delete'), 3493 (OtherReferenceChange, 'update'), 3494 (OtherReferenceChange, 'create'), 3495 (OtherReferenceChange, 'delete'), 3496 ]) 3497 ) 3498 3499 def __init__(self, environment, changes, ignore_other_refs=False): 3500 self.changes = sorted(changes, key=self._sort_key) 3501 self.__other_ref_sha1s = None 3502 self.__cached_commits_spec = {} 3503 self.environment = environment 3504 3505 if ignore_other_refs: 3506 self.__other_ref_sha1s = set() 3507 3508 @classmethod 3509 def _sort_key(klass, change): 3510 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,) 3511 3512 @property 3513 def _other_ref_sha1s(self): 3514 """The GitObjects referred to by references unaffected by this push. 3515 """ 3516 if self.__other_ref_sha1s is None: 3517 # The refnames being changed by this push: 3518 updated_refs = set( 3519 change.refname 3520 for change in self.changes 3521 ) 3522 3523 # The SHA-1s of commits referred to by all references in this 3524 # repository *except* updated_refs: 3525 sha1s = set() 3526 fmt = ( 3527 '%(objectname) %(objecttype) %(refname)\n' 3528 '%(*objectname) %(*objecttype) %(refname)' 3529 ) 3530 ref_filter_regex, is_inclusion_filter = \ 3531 self.environment.get_ref_filter_regex() 3532 for line in read_git_lines( 3533 ['for-each-ref', '--format=%s' % (fmt,)]): 3534 (sha1, type, name) = line.split(' ', 2) 3535 if (sha1 and type == 'commit' and 3536 name not in updated_refs and 3537 include_ref(name, ref_filter_regex, is_inclusion_filter)): 3538 sha1s.add(sha1) 3539 3540 self.__other_ref_sha1s = sha1s 3541 3542 return self.__other_ref_sha1s 3543 3544 def _get_commits_spec_incl(self, new_or_old, reference_change=None): 3545 """Get new or old SHA-1 from one or each of the changed refs. 3546 3547 Return a list of SHA-1 commit identifier strings suitable as 3548 arguments to 'git rev-list' (or 'git log' or ...). The 3549 returned identifiers are either the old or new values from one 3550 or all of the changed references, depending on the values of 3551 new_or_old and reference_change. 3552 3553 new_or_old is either the string 'new' or the string 'old'. If 3554 'new', the returned SHA-1 identifiers are the new values from 3555 each changed reference. If 'old', the SHA-1 identifiers are 3556 the old values from each changed reference. 3557 3558 If reference_change is specified and not None, only the new or 3559 old reference from the specified reference is included in the 3560 return value. 3561 3562 This function returns None if there are no matching revisions 3563 (e.g., because a branch was deleted and new_or_old is 'new'). 3564 """ 3565 3566 if not reference_change: 3567 incl_spec = sorted( 3568 getattr(change, new_or_old).sha1 3569 for change in self.changes 3570 if getattr(change, new_or_old) 3571 ) 3572 if not incl_spec: 3573 incl_spec = None 3574 elif not getattr(reference_change, new_or_old).commit_sha1: 3575 incl_spec = None 3576 else: 3577 incl_spec = [getattr(reference_change, new_or_old).commit_sha1] 3578 return incl_spec 3579 3580 def _get_commits_spec_excl(self, new_or_old): 3581 """Get exclusion revisions for determining new or discarded commits. 3582 3583 Return a list of strings suitable as arguments to 'git 3584 rev-list' (or 'git log' or ...) that will exclude all 3585 commits that, depending on the value of new_or_old, were 3586 either previously in the repository (useful for determining 3587 which commits are new to the repository) or currently in the 3588 repository (useful for determining which commits were 3589 discarded from the repository). 3590 3591 new_or_old is either the string 'new' or the string 'old'. If 3592 'new', the commits to be excluded are those that were in the 3593 repository before the push. If 'old', the commits to be 3594 excluded are those that are currently in the repository. """ 3595 3596 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old] 3597 excl_revs = self._other_ref_sha1s.union( 3598 getattr(change, old_or_new).sha1 3599 for change in self.changes 3600 if getattr(change, old_or_new).type in ['commit', 'tag'] 3601 ) 3602 return ['^' + sha1 for sha1 in sorted(excl_revs)] 3603 3604 def get_commits_spec(self, new_or_old, reference_change=None): 3605 """Get rev-list arguments for added or discarded commits. 3606 3607 Return a list of strings suitable as arguments to 'git 3608 rev-list' (or 'git log' or ...) that select those commits 3609 that, depending on the value of new_or_old, are either new to 3610 the repository or were discarded from the repository. 3611 3612 new_or_old is either the string 'new' or the string 'old'. If 3613 'new', the returned list is used to select commits that are 3614 new to the repository. If 'old', the returned value is used 3615 to select the commits that have been discarded from the 3616 repository. 3617 3618 If reference_change is specified and not None, the new or 3619 discarded commits are limited to those that are reachable from 3620 the new or old value of the specified reference. 3621 3622 This function returns None if there are no added (or discarded) 3623 revisions. 3624 """ 3625 key = (new_or_old, reference_change) 3626 if key not in self.__cached_commits_spec: 3627 ret = self._get_commits_spec_incl(new_or_old, reference_change) 3628 if ret is not None: 3629 ret.extend(self._get_commits_spec_excl(new_or_old)) 3630 self.__cached_commits_spec[key] = ret 3631 return self.__cached_commits_spec[key] 3632 3633 def get_new_commits(self, reference_change=None): 3634 """Return a list of commits added by this push. 3635 3636 Return a list of the object names of commits that were added 3637 by the part of this push represented by reference_change. If 3638 reference_change is None, then return a list of *all* commits 3639 added by this push.""" 3640 3641 spec = self.get_commits_spec('new', reference_change) 3642 return git_rev_list(spec) 3643 3644 def get_discarded_commits(self, reference_change): 3645 """Return a list of commits discarded by this push. 3646 3647 Return a list of the object names of commits that were 3648 entirely discarded from the repository by the part of this 3649 push represented by reference_change.""" 3650 3651 spec = self.get_commits_spec('old', reference_change) 3652 return git_rev_list(spec) 3653 3654 def send_emails(self, mailer, body_filter=None): 3655 """Use send all of the notification emails needed for this push. 3656 3657 Use send all of the notification emails (including reference 3658 change emails and commit emails) needed for this push. Send 3659 the emails using mailer. If body_filter is not None, then use 3660 it to filter the lines that are intended for the email 3661 body.""" 3662 3663 # The sha1s of commits that were introduced by this push. 3664 # They will be removed from this set as they are processed, to 3665 # guarantee that one (and only one) email is generated for 3666 # each new commit. 3667 unhandled_sha1s = set(self.get_new_commits()) 3668 send_date = IncrementalDateTime() 3669 for change in self.changes: 3670 sha1s = [] 3671 for sha1 in reversed(list(self.get_new_commits(change))): 3672 if sha1 in unhandled_sha1s: 3673 sha1s.append(sha1) 3674 unhandled_sha1s.remove(sha1) 3675 3676 # Check if we've got anyone to send to 3677 if not change.recipients: 3678 change.environment.log_warning( 3679 '*** no recipients configured so no email will be sent\n' 3680 '*** for %r update %s->%s' 3681 % (change.refname, change.old.sha1, change.new.sha1,) 3682 ) 3683 else: 3684 if not change.environment.quiet: 3685 change.environment.log_msg( 3686 'Sending notification emails to: %s' % (change.recipients,)) 3687 extra_values = {'send_date': next(send_date)} 3688 3689 rev = change.send_single_combined_email(sha1s) 3690 if rev: 3691 mailer.send( 3692 change.generate_combined_email(self, rev, body_filter, extra_values), 3693 rev.recipients, 3694 ) 3695 # This change is now fully handled; no need to handle 3696 # individual revisions any further. 3697 continue 3698 else: 3699 mailer.send( 3700 change.generate_email(self, body_filter, extra_values), 3701 change.recipients, 3702 ) 3703 3704 max_emails = change.environment.maxcommitemails 3705 if max_emails and len(sha1s) > max_emails: 3706 change.environment.log_warning( 3707 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) + 3708 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' + 3709 '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails 3710 ) 3711 return 3712 3713 for (num, sha1) in enumerate(sha1s): 3714 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s)) 3715 if len(rev.parents) > 1 and change.environment.excludemergerevisions: 3716 # skipping a merge commit 3717 continue 3718 if not rev.recipients and rev.cc_recipients: 3719 change.environment.log_msg('*** Replacing Cc: with To:') 3720 rev.recipients = rev.cc_recipients 3721 rev.cc_recipients = None 3722 if rev.recipients: 3723 extra_values = {'send_date': next(send_date)} 3724 mailer.send( 3725 rev.generate_email(self, body_filter, extra_values), 3726 rev.recipients, 3727 ) 3728 3729 # Consistency check: 3730 if unhandled_sha1s: 3731 change.environment.log_error( 3732 'ERROR: No emails were sent for the following new commits:\n' 3733 ' %s' 3734 % ('\n '.join(sorted(unhandled_sha1s)),) 3735 ) 3736 3737 3738def include_ref(refname, ref_filter_regex, is_inclusion_filter): 3739 does_match = bool(ref_filter_regex.search(refname)) 3740 if is_inclusion_filter: 3741 return does_match 3742 else: # exclusion filter -- we include the ref if the regex doesn't match 3743 return not does_match 3744 3745 3746def run_as_post_receive_hook(environment, mailer): 3747 environment.check() 3748 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True) 3749 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False) 3750 changes = [] 3751 while True: 3752 line = read_line(sys.stdin) 3753 if line == '': 3754 break 3755 (oldrev, newrev, refname) = line.strip().split(' ', 2) 3756 environment.get_logger().debug( 3757 "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" % 3758 (oldrev, newrev, refname)) 3759 3760 if not include_ref(refname, ref_filter_regex, is_inclusion_filter): 3761 continue 3762 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter): 3763 continue 3764 changes.append( 3765 ReferenceChange.create(environment, oldrev, newrev, refname) 3766 ) 3767 if not changes: 3768 mailer.close() 3769 return 3770 push = Push(environment, changes) 3771 try: 3772 push.send_emails(mailer, body_filter=environment.filter_body) 3773 finally: 3774 mailer.close() 3775 3776 3777def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False): 3778 environment.check() 3779 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True) 3780 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False) 3781 if not include_ref(refname, ref_filter_regex, is_inclusion_filter): 3782 return 3783 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter): 3784 return 3785 changes = [ 3786 ReferenceChange.create( 3787 environment, 3788 read_git_output(['rev-parse', '--verify', oldrev]), 3789 read_git_output(['rev-parse', '--verify', newrev]), 3790 refname, 3791 ), 3792 ] 3793 if not changes: 3794 mailer.close() 3795 return 3796 push = Push(environment, changes, force_send) 3797 try: 3798 push.send_emails(mailer, body_filter=environment.filter_body) 3799 finally: 3800 mailer.close() 3801 3802 3803def check_ref_filter(environment): 3804 send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True) 3805 ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False) 3806 3807 def inc_exc_lusion(b): 3808 if b: 3809 return 'inclusion' 3810 else: 3811 return 'exclusion' 3812 3813 if send_filter_regex: 3814 sys.stdout.write("DoSend/DontSend filter regex (" + 3815 (inc_exc_lusion(send_is_inclusion)) + 3816 '): ' + send_filter_regex.pattern + 3817 '\n') 3818 if send_filter_regex: 3819 sys.stdout.write("Include/Exclude filter regex (" + 3820 (inc_exc_lusion(ref_is_inclusion)) + 3821 '): ' + ref_filter_regex.pattern + 3822 '\n') 3823 sys.stdout.write(os.linesep) 3824 3825 sys.stdout.write( 3826 "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n" 3827 "or refFilterExclusionRegex. No emails will be sent for commits included\n" 3828 "in these refs.\n" 3829 "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n" 3830 "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n" 3831 "refFilterExclusionRegex. Emails will be sent for commits included in these\n" 3832 "refs only when the commit reaches a ref which isn't excluded.\n" 3833 "Refs marked as DO-SEND are not excluded by any filter. Emails will\n" 3834 "be sent normally for commits included in these refs.\n") 3835 3836 sys.stdout.write(os.linesep) 3837 3838 for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']): 3839 sys.stdout.write(refname) 3840 if not include_ref(refname, ref_filter_regex, ref_is_inclusion): 3841 sys.stdout.write(' EXCLUDE') 3842 elif not include_ref(refname, send_filter_regex, send_is_inclusion): 3843 sys.stdout.write(' DONT-SEND') 3844 else: 3845 sys.stdout.write(' DO-SEND') 3846 3847 sys.stdout.write(os.linesep) 3848 3849 3850def show_env(environment, out): 3851 out.write('Environment values:\n') 3852 for (k, v) in sorted(environment.get_values().items()): 3853 if k: # Don't show the {'' : ''} pair. 3854 out.write(' %s : %r\n' % (k, v)) 3855 out.write('\n') 3856 # Flush to avoid interleaving with further log output 3857 out.flush() 3858 3859 3860def check_setup(environment): 3861 environment.check() 3862 show_env(environment, sys.stdout) 3863 sys.stdout.write("Now, checking that git-multimail's standard input " 3864 "is properly set ..." + os.linesep) 3865 sys.stdout.write("Please type some text and then press Return" + os.linesep) 3866 stdin = sys.stdin.readline() 3867 sys.stdout.write("You have just entered:" + os.linesep) 3868 sys.stdout.write(stdin) 3869 sys.stdout.write("git-multimail seems properly set up." + os.linesep) 3870 3871 3872def choose_mailer(config, environment): 3873 mailer = config.get('mailer', default='sendmail') 3874 3875 if mailer == 'smtp': 3876 smtpserver = config.get('smtpserver', default='localhost') 3877 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0)) 3878 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0)) 3879 smtpencryption = config.get('smtpencryption', default='none') 3880 smtpuser = config.get('smtpuser', default='') 3881 smtppass = config.get('smtppass', default='') 3882 smtpcacerts = config.get('smtpcacerts', default='') 3883 mailer = SMTPMailer( 3884 environment, 3885 envelopesender=(environment.get_sender() or environment.get_fromaddr()), 3886 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout, 3887 smtpserverdebuglevel=smtpserverdebuglevel, 3888 smtpencryption=smtpencryption, 3889 smtpuser=smtpuser, 3890 smtppass=smtppass, 3891 smtpcacerts=smtpcacerts 3892 ) 3893 elif mailer == 'sendmail': 3894 command = config.get('sendmailcommand') 3895 if command: 3896 command = shlex.split(command) 3897 mailer = SendMailer(environment, 3898 command=command, envelopesender=environment.get_sender()) 3899 else: 3900 environment.log_error( 3901 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer + 3902 'please use one of "smtp" or "sendmail".' 3903 ) 3904 sys.exit(1) 3905 return mailer 3906 3907 3908KNOWN_ENVIRONMENTS = { 3909 'generic': {'highprec': GenericEnvironmentMixin}, 3910 'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin, 3911 'lowprec': GitoliteEnvironmentLowPrecMixin}, 3912 'stash': {'highprec': StashEnvironmentHighPrecMixin, 3913 'lowprec': StashEnvironmentLowPrecMixin}, 3914 'gerrit': {'highprec': GerritEnvironmentHighPrecMixin, 3915 'lowprec': GerritEnvironmentLowPrecMixin}, 3916 } 3917 3918 3919def choose_environment(config, osenv=None, env=None, recipients=None, 3920 hook_info=None): 3921 env_name = choose_environment_name(config, env, osenv) 3922 environment_klass = build_environment_klass(env_name) 3923 env = build_environment(environment_klass, env_name, config, 3924 osenv, recipients, hook_info) 3925 return env 3926 3927 3928def choose_environment_name(config, env, osenv): 3929 if not osenv: 3930 osenv = os.environ 3931 3932 if not env: 3933 env = config.get('environment') 3934 3935 if not env: 3936 if 'GL_USER' in osenv and 'GL_REPO' in osenv: 3937 env = 'gitolite' 3938 else: 3939 env = 'generic' 3940 return env 3941 3942 3943COMMON_ENVIRONMENT_MIXINS = [ 3944 ConfigRecipientsEnvironmentMixin, 3945 CLIRecipientsEnvironmentMixin, 3946 ConfigRefFilterEnvironmentMixin, 3947 ProjectdescEnvironmentMixin, 3948 ConfigMaxlinesEnvironmentMixin, 3949 ComputeFQDNEnvironmentMixin, 3950 ConfigFilterLinesEnvironmentMixin, 3951 PusherDomainEnvironmentMixin, 3952 ConfigOptionsEnvironmentMixin, 3953 ] 3954 3955 3956def build_environment_klass(env_name): 3957 if 'class' in KNOWN_ENVIRONMENTS[env_name]: 3958 return KNOWN_ENVIRONMENTS[env_name]['class'] 3959 3960 environment_mixins = [] 3961 known_env = KNOWN_ENVIRONMENTS[env_name] 3962 if 'highprec' in known_env: 3963 high_prec_mixin = known_env['highprec'] 3964 environment_mixins.append(high_prec_mixin) 3965 environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS 3966 if 'lowprec' in known_env: 3967 low_prec_mixin = known_env['lowprec'] 3968 environment_mixins.append(low_prec_mixin) 3969 environment_mixins.append(Environment) 3970 klass_name = env_name.capitalize() + 'Environment' 3971 environment_klass = type( 3972 klass_name, 3973 tuple(environment_mixins), 3974 {}, 3975 ) 3976 KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass 3977 return environment_klass 3978 3979 3980GerritEnvironment = build_environment_klass('gerrit') 3981StashEnvironment = build_environment_klass('stash') 3982GitoliteEnvironment = build_environment_klass('gitolite') 3983GenericEnvironment = build_environment_klass('generic') 3984 3985 3986def build_environment(environment_klass, env, config, 3987 osenv, recipients, hook_info): 3988 environment_kw = { 3989 'osenv': osenv, 3990 'config': config, 3991 } 3992 3993 if env == 'stash': 3994 environment_kw['user'] = hook_info['stash_user'] 3995 environment_kw['repo'] = hook_info['stash_repo'] 3996 elif env == 'gerrit': 3997 environment_kw['project'] = hook_info['project'] 3998 environment_kw['submitter'] = hook_info['submitter'] 3999 environment_kw['update_method'] = hook_info['update_method'] 4000 4001 environment_kw['cli_recipients'] = recipients 4002 4003 return environment_klass(**environment_kw) 4004 4005 4006def get_version(): 4007 oldcwd = os.getcwd() 4008 try: 4009 try: 4010 os.chdir(os.path.dirname(os.path.realpath(__file__))) 4011 git_version = read_git_output(['describe', '--tags', 'HEAD']) 4012 if git_version == __version__: 4013 return git_version 4014 else: 4015 return '%s (%s)' % (__version__, git_version) 4016 except: 4017 pass 4018 finally: 4019 os.chdir(oldcwd) 4020 return __version__ 4021 4022 4023def compute_gerrit_options(options, args, required_gerrit_options, 4024 raw_refname): 4025 if None in required_gerrit_options: 4026 raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, " 4027 "and --project; or none of them.") 4028 4029 if options.environment not in (None, 'gerrit'): 4030 raise SystemExit("Non-gerrit environments incompatible with --oldrev, " 4031 "--newrev, --refname, and --project") 4032 options.environment = 'gerrit' 4033 4034 if args: 4035 raise SystemExit("Error: Positional parameters not allowed with " 4036 "--oldrev, --newrev, and --refname.") 4037 4038 # Gerrit oddly omits 'refs/heads/' in the refname when calling 4039 # ref-updated hook; put it back. 4040 git_dir = get_git_dir() 4041 if (not os.path.exists(os.path.join(git_dir, raw_refname)) and 4042 os.path.exists(os.path.join(git_dir, 'refs', 'heads', 4043 raw_refname))): 4044 options.refname = 'refs/heads/' + options.refname 4045 4046 # New revisions can appear in a gerrit repository either due to someone 4047 # pushing directly (in which case options.submitter will be set), or they 4048 # can press "Submit this patchset" in the web UI for some CR (in which 4049 # case options.submitter will not be set and gerrit will not have provided 4050 # us the information about who pressed the button). 4051 # 4052 # Note for the nit-picky: I'm lumping in REST API calls and the ssh 4053 # gerrit review command in with "Submit this patchset" button, since they 4054 # have the same effect. 4055 if options.submitter: 4056 update_method = 'pushed' 4057 # The submitter argument is almost an RFC 2822 email address; change it 4058 # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is 4059 options.submitter = options.submitter.replace('(', '<').replace(')', '>') 4060 else: 4061 update_method = 'submitted' 4062 # Gerrit knew who submitted this patchset, but threw that information 4063 # away when it invoked this hook. However, *IF* Gerrit created a 4064 # merge to bring the patchset in (project 'Submit Type' is either 4065 # "Always Merge", or is "Merge if Necessary" and happens to be 4066 # necessary for this particular CR), then it will have the committer 4067 # of that merge be 'Gerrit Code Review' and the author will be the 4068 # person who requested the submission of the CR. Since this is fairly 4069 # likely for most gerrit installations (of a reasonable size), it's 4070 # worth the extra effort to try to determine the actual submitter. 4071 rev_info = read_git_lines(['log', '--no-walk', '--merges', 4072 '--format=%cN%n%aN <%aE>', options.newrev]) 4073 if rev_info and rev_info[0] == 'Gerrit Code Review': 4074 options.submitter = rev_info[1] 4075 4076 # We pass back refname, oldrev, newrev as args because then the 4077 # gerrit ref-updated hook is much like the git update hook 4078 return (options, 4079 [options.refname, options.oldrev, options.newrev], 4080 {'project': options.project, 'submitter': options.submitter, 4081 'update_method': update_method}) 4082 4083 4084def check_hook_specific_args(options, args): 4085 raw_refname = options.refname 4086 # Convert each string option unicode for Python3. 4087 if PYTHON3: 4088 opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname', 4089 'project', 'submitter', 'stash_user', 'stash_repo'] 4090 for opt in opts: 4091 if not hasattr(options, opt): 4092 continue 4093 obj = getattr(options, opt) 4094 if obj: 4095 enc = obj.encode('utf-8', 'surrogateescape') 4096 dec = enc.decode('utf-8', 'replace') 4097 setattr(options, opt, dec) 4098 4099 # First check for stash arguments 4100 if (options.stash_user is None) != (options.stash_repo is None): 4101 raise SystemExit("Error: Specify both of --stash-user and " 4102 "--stash-repo or neither.") 4103 if options.stash_user: 4104 options.environment = 'stash' 4105 return options, args, {'stash_user': options.stash_user, 4106 'stash_repo': options.stash_repo} 4107 4108 # Finally, check for gerrit specific arguments 4109 required_gerrit_options = (options.oldrev, options.newrev, options.refname, 4110 options.project) 4111 if required_gerrit_options != (None,) * 4: 4112 return compute_gerrit_options(options, args, required_gerrit_options, 4113 raw_refname) 4114 4115 # No special options in use, just return what we started with 4116 return options, args, {} 4117 4118 4119class Logger(object): 4120 def parse_verbose(self, verbose): 4121 if verbose > 0: 4122 return logging.DEBUG 4123 else: 4124 return logging.INFO 4125 4126 def create_log_file(self, environment, name, path, verbosity): 4127 log_file = logging.getLogger(name) 4128 file_handler = logging.FileHandler(path) 4129 log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") 4130 file_handler.setFormatter(log_fmt) 4131 log_file.addHandler(file_handler) 4132 log_file.setLevel(verbosity) 4133 return log_file 4134 4135 def __init__(self, environment): 4136 self.environment = environment 4137 self.loggers = [] 4138 stderr_log = logging.getLogger('git_multimail.stderr') 4139 4140 class EncodedStderr(object): 4141 def write(self, x): 4142 write_str(sys.stderr, x) 4143 4144 def flush(self): 4145 sys.stderr.flush() 4146 4147 stderr_handler = logging.StreamHandler(EncodedStderr()) 4148 stderr_log.addHandler(stderr_handler) 4149 stderr_log.setLevel(self.parse_verbose(environment.verbose)) 4150 self.loggers.append(stderr_log) 4151 4152 if environment.debug_log_file is not None: 4153 debug_log_file = self.create_log_file( 4154 environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG) 4155 self.loggers.append(debug_log_file) 4156 4157 if environment.log_file is not None: 4158 log_file = self.create_log_file( 4159 environment, 'git_multimail.file', environment.log_file, logging.INFO) 4160 self.loggers.append(log_file) 4161 4162 if environment.error_log_file is not None: 4163 error_log_file = self.create_log_file( 4164 environment, 'git_multimail.error', environment.error_log_file, logging.ERROR) 4165 self.loggers.append(error_log_file) 4166 4167 def info(self, msg, *args, **kwargs): 4168 for l in self.loggers: 4169 l.info(msg, *args, **kwargs) 4170 4171 def debug(self, msg, *args, **kwargs): 4172 for l in self.loggers: 4173 l.debug(msg, *args, **kwargs) 4174 4175 def warning(self, msg, *args, **kwargs): 4176 for l in self.loggers: 4177 l.warning(msg, *args, **kwargs) 4178 4179 def error(self, msg, *args, **kwargs): 4180 for l in self.loggers: 4181 l.error(msg, *args, **kwargs) 4182 4183 4184def main(args): 4185 parser = optparse.OptionParser( 4186 description=__doc__, 4187 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV', 4188 ) 4189 4190 parser.add_option( 4191 '--environment', '--env', action='store', type='choice', 4192 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None, 4193 help=( 4194 'Choose type of environment is in use. Default is taken from ' 4195 'multimailhook.environment if set; otherwise "generic".' 4196 ), 4197 ) 4198 parser.add_option( 4199 '--stdout', action='store_true', default=False, 4200 help='Output emails to stdout rather than sending them.', 4201 ) 4202 parser.add_option( 4203 '--recipients', action='store', default=None, 4204 help='Set list of email recipients for all types of emails.', 4205 ) 4206 parser.add_option( 4207 '--show-env', action='store_true', default=False, 4208 help=( 4209 'Write to stderr the values determined for the environment ' 4210 '(intended for debugging purposes), then proceed normally.' 4211 ), 4212 ) 4213 parser.add_option( 4214 '--force-send', action='store_true', default=False, 4215 help=( 4216 'Force sending refchange email when using as an update hook. ' 4217 'This is useful to work around the unreliable new commits ' 4218 'detection in this mode.' 4219 ), 4220 ) 4221 parser.add_option( 4222 '-c', metavar="<name>=<value>", action='append', 4223 help=( 4224 'Pass a configuration parameter through to git. The value given ' 4225 'will override values from configuration files. See the -c option ' 4226 'of git(1) for more details. (Only works with git >= 1.7.3)' 4227 ), 4228 ) 4229 parser.add_option( 4230 '--version', '-v', action='store_true', default=False, 4231 help=( 4232 "Display git-multimail's version" 4233 ), 4234 ) 4235 4236 parser.add_option( 4237 '--python-version', action='store_true', default=False, 4238 help=( 4239 "Display the version of Python used by git-multimail" 4240 ), 4241 ) 4242 4243 parser.add_option( 4244 '--check-ref-filter', action='store_true', default=False, 4245 help=( 4246 'List refs and show information on how git-multimail ' 4247 'will process them.' 4248 ) 4249 ) 4250 4251 # The following options permit this script to be run as a gerrit 4252 # ref-updated hook. See e.g. 4253 # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt 4254 # We suppress help for these items, since these are specific to gerrit, 4255 # and we don't want users directly using them any way other than how the 4256 # gerrit ref-updated hook is called. 4257 parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP) 4258 parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP) 4259 parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP) 4260 parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP) 4261 parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP) 4262 4263 # The following allow this to be run as a stash asynchronous post-receive 4264 # hook (almost identical to a git post-receive hook but triggered also for 4265 # merges of pull requests from the UI). We suppress help for these items, 4266 # since these are specific to stash. 4267 parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP) 4268 parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP) 4269 4270 (options, args) = parser.parse_args(args) 4271 (options, args, hook_info) = check_hook_specific_args(options, args) 4272 4273 if options.version: 4274 sys.stdout.write('git-multimail version ' + get_version() + '\n') 4275 return 4276 4277 if options.python_version: 4278 sys.stdout.write('Python version ' + sys.version + '\n') 4279 return 4280 4281 if options.c: 4282 Config.add_config_parameters(options.c) 4283 4284 config = Config('multimailhook') 4285 4286 environment = None 4287 try: 4288 environment = choose_environment( 4289 config, osenv=os.environ, 4290 env=options.environment, 4291 recipients=options.recipients, 4292 hook_info=hook_info, 4293 ) 4294 4295 if options.show_env: 4296 show_env(environment, sys.stderr) 4297 4298 if options.stdout or environment.stdout: 4299 mailer = OutputMailer(sys.stdout, environment) 4300 else: 4301 mailer = choose_mailer(config, environment) 4302 4303 must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP') 4304 if must_check_setup == '': 4305 must_check_setup = False 4306 if options.check_ref_filter: 4307 check_ref_filter(environment) 4308 elif must_check_setup: 4309 check_setup(environment) 4310 # Dual mode: if arguments were specified on the command line, run 4311 # like an update hook; otherwise, run as a post-receive hook. 4312 elif args: 4313 if len(args) != 3: 4314 parser.error('Need zero or three non-option arguments') 4315 (refname, oldrev, newrev) = args 4316 environment.get_logger().debug( 4317 "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" % 4318 (refname, oldrev, newrev, options.force_send)) 4319 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send) 4320 else: 4321 run_as_post_receive_hook(environment, mailer) 4322 except ConfigurationException: 4323 sys.exit(sys.exc_info()[1]) 4324 except SystemExit: 4325 raise 4326 except Exception: 4327 t, e, tb = sys.exc_info() 4328 import traceback 4329 sys.stderr.write('\n') # Avoid mixing message with previous output 4330 msg = ( 4331 'Exception \'' + t.__name__ + 4332 '\' raised. Please report this as a bug to\n' 4333 'https://github.com/git-multimail/git-multimail/issues\n' 4334 'with the information below:\n\n' 4335 'git-multimail version ' + get_version() + '\n' 4336 'Python version ' + sys.version + '\n' + 4337 traceback.format_exc()) 4338 try: 4339 environment.get_logger().error(msg) 4340 except: 4341 sys.stderr.write(msg) 4342 sys.exit(1) 4343 4344 4345if __name__ == '__main__': 4346 main(sys.argv[1:]) 4347