1#! /usr/bin/env python 2 3__version__ ='1.3.1' 4 5# Copyright (c) 2015 Matthieu Moy and others 6# Copyright (c) 2012-2014 Michael Haggerty and others 7# Derived from contrib/hooks/post-receive-email, which is 8# Copyright (c) 2007 Andy Parkins 9# and also includes contributions by other authors. 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 smtplib 60try: 61import ssl 62exceptImportError: 63# Python < 2.6 do not have ssl, but that's OK if we don't use it. 64pass 65import time 66import cgi 67 68PYTHON3 = sys.version_info >= (3,0) 69 70if sys.version_info <= (2,5): 71defall(iterable): 72for element in iterable: 73if not element: 74return False 75return True 76 77 78defis_ascii(s): 79returnall(ord(c) <128andord(c) >0for c in s) 80 81 82if PYTHON3: 83defis_string(s): 84returnisinstance(s,str) 85 86defstr_to_bytes(s): 87return s.encode(ENCODING) 88 89defbytes_to_str(s): 90return s.decode(ENCODING) 91 92unicode=str 93 94defwrite_str(f, msg): 95# Try outputing with the default encoding. If it fails, 96# try UTF-8. 97try: 98 f.buffer.write(msg.encode(sys.getdefaultencoding())) 99exceptUnicodeEncodeError: 100 f.buffer.write(msg.encode(ENCODING)) 101else: 102defis_string(s): 103try: 104returnisinstance(s, basestring) 105exceptNameError:# Silence Pyflakes warning 106raise 107 108defstr_to_bytes(s): 109return s 110 111defbytes_to_str(s): 112return s 113 114defwrite_str(f, msg): 115 f.write(msg) 116 117defnext(it): 118return it.next() 119 120 121try: 122from email.charset import Charset 123from email.utils import make_msgid 124from email.utils import getaddresses 125from email.utils import formataddr 126from email.utils import formatdate 127from email.header import Header 128exceptImportError: 129# Prior to Python 2.5, the email module used different names: 130from email.Charset import Charset 131from email.Utils import make_msgid 132from email.Utils import getaddresses 133from email.Utils import formataddr 134from email.Utils import formatdate 135from email.Header import Header 136 137 138DEBUG =False 139 140ZEROS ='0'*40 141LOGBEGIN ='- Log -----------------------------------------------------------------\n' 142LOGEND ='-----------------------------------------------------------------------\n' 143 144ADDR_HEADERS =set(['from','to','cc','bcc','reply-to','sender']) 145 146# It is assumed in many places that the encoding is uniformly UTF-8, 147# so changing these constants is unsupported. But define them here 148# anyway, to make it easier to find (at least most of) the places 149# where the encoding is important. 150(ENCODING, CHARSET) = ('UTF-8','utf-8') 151 152 153REF_CREATED_SUBJECT_TEMPLATE = ( 154'%(emailprefix)s%(refname_type)s %(short_refname)screated' 155' (now%(newrev_short)s)' 156) 157REF_UPDATED_SUBJECT_TEMPLATE = ( 158'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 159' (%(oldrev_short)s->%(newrev_short)s)' 160) 161REF_DELETED_SUBJECT_TEMPLATE = ( 162'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 163' (was%(oldrev_short)s)' 164) 165 166COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( 167'%(emailprefix)s%(refname_type)s %(short_refname)supdated:%(oneline)s' 168) 169 170REFCHANGE_HEADER_TEMPLATE ="""\ 171Date:%(send_date)s 172To:%(recipients)s 173Subject:%(subject)s 174MIME-Version: 1.0 175Content-Type: text/%(contenttype)s; charset=%(charset)s 176Content-Transfer-Encoding: 8bit 177Message-ID:%(msgid)s 178From:%(fromaddr)s 179Reply-To:%(reply_to)s 180X-Git-Host:%(fqdn)s 181X-Git-Repo:%(repo_shortname)s 182X-Git-Refname:%(refname)s 183X-Git-Reftype:%(refname_type)s 184X-Git-Oldrev:%(oldrev)s 185X-Git-Newrev:%(newrev)s 186X-Git-NotificationType: ref_changed 187X-Git-Multimail-Version:%(multimail_version)s 188Auto-Submitted: auto-generated 189""" 190 191REFCHANGE_INTRO_TEMPLATE ="""\ 192This is an automated email from the git hooks/post-receive script. 193 194%(pusher)spushed a change to%(refname_type)s %(short_refname)s 195in repository%(repo_shortname)s. 196 197""" 198 199 200FOOTER_TEMPLATE ="""\ 201 202--\n\ 203To stop receiving notification emails like this one, please contact 204%(administrator)s. 205""" 206 207 208REWIND_ONLY_TEMPLATE ="""\ 209This update removed existing revisions from the reference, leaving the 210reference pointing at a previous point in the repository history. 211 212 * -- * -- N%(refname)s(%(newrev_short)s) 213\\ 214 O -- O -- O (%(oldrev_short)s) 215 216Any revisions marked "omits" are not gone; other references still 217refer to them. Any revisions marked "discards" are gone forever. 218""" 219 220 221NON_FF_TEMPLATE ="""\ 222This update added new revisions after undoing existing revisions. 223That is to say, some revisions that were in the old version of the 224%(refname_type)sare not in the new version. This situation occurs 225when a user --force pushes a change and generates a repository 226containing something like this: 227 228 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 229\\ 230 N -- N -- N%(refname)s(%(newrev_short)s) 231 232You should already have received notification emails for all of the O 233revisions, and so the following emails describe only the N revisions 234from the common base, B. 235 236Any revisions marked "omits" are not gone; other references still 237refer to them. Any revisions marked "discards" are gone forever. 238""" 239 240 241NO_NEW_REVISIONS_TEMPLATE ="""\ 242No new revisions were added by this update. 243""" 244 245 246DISCARDED_REVISIONS_TEMPLATE ="""\ 247This change permanently discards the following revisions: 248""" 249 250 251NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 252The revisions that were on this%(refname_type)sare still contained in 253other references; therefore, this change does not discard any commits 254from the repository. 255""" 256 257 258NEW_REVISIONS_TEMPLATE ="""\ 259The%(tot)srevisions listed above as "new" are entirely new to this 260repository and will be described in separate emails. The revisions 261listed as "adds" were already present in the repository and have only 262been added to this reference. 263 264""" 265 266 267TAG_CREATED_TEMPLATE ="""\ 268 at%(newrev_short)-9s (%(newrev_type)s) 269""" 270 271 272TAG_UPDATED_TEMPLATE ="""\ 273*** WARNING: tag%(short_refname)swas modified! *** 274 275 from%(oldrev_short)-9s (%(oldrev_type)s) 276 to%(newrev_short)-9s (%(newrev_type)s) 277""" 278 279 280TAG_DELETED_TEMPLATE ="""\ 281*** WARNING: tag%(short_refname)swas deleted! *** 282 283""" 284 285 286# The template used in summary tables. It looks best if this uses the 287# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 288BRIEF_SUMMARY_TEMPLATE ="""\ 289%(action)10s%(rev_short)-9s%(text)s 290""" 291 292 293NON_COMMIT_UPDATE_TEMPLATE ="""\ 294This is an unusual reference change because the reference did not 295refer to a commit either before or after the change. We do not know 296how to provide full information about this reference change. 297""" 298 299 300REVISION_HEADER_TEMPLATE ="""\ 301Date:%(send_date)s 302To:%(recipients)s 303Cc:%(cc_recipients)s 304Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 305MIME-Version: 1.0 306Content-Type: text/%(contenttype)s; charset=%(charset)s 307Content-Transfer-Encoding: 8bit 308From:%(fromaddr)s 309Reply-To:%(reply_to)s 310In-Reply-To:%(reply_to_msgid)s 311References:%(reply_to_msgid)s 312X-Git-Host:%(fqdn)s 313X-Git-Repo:%(repo_shortname)s 314X-Git-Refname:%(refname)s 315X-Git-Reftype:%(refname_type)s 316X-Git-Rev:%(rev)s 317X-Git-NotificationType: diff 318X-Git-Multimail-Version:%(multimail_version)s 319Auto-Submitted: auto-generated 320""" 321 322REVISION_INTRO_TEMPLATE ="""\ 323This is an automated email from the git hooks/post-receive script. 324 325%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 326in repository%(repo_shortname)s. 327 328""" 329 330LINK_TEXT_TEMPLATE ="""\ 331View the commit online: 332%(browse_url)s 333 334""" 335 336LINK_HTML_TEMPLATE ="""\ 337<p><a href="%(browse_url)s">View the commit online</a>.</p> 338""" 339 340 341REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 342 343 344# Combined, meaning refchange+revision email (for single-commit additions) 345COMBINED_HEADER_TEMPLATE ="""\ 346Date:%(send_date)s 347To:%(recipients)s 348Subject:%(subject)s 349MIME-Version: 1.0 350Content-Type: text/%(contenttype)s; charset=%(charset)s 351Content-Transfer-Encoding: 8bit 352Message-ID:%(msgid)s 353From:%(fromaddr)s 354Reply-To:%(reply_to)s 355X-Git-Host:%(fqdn)s 356X-Git-Repo:%(repo_shortname)s 357X-Git-Refname:%(refname)s 358X-Git-Reftype:%(refname_type)s 359X-Git-Oldrev:%(oldrev)s 360X-Git-Newrev:%(newrev)s 361X-Git-Rev:%(rev)s 362X-Git-NotificationType: ref_changed_plus_diff 363X-Git-Multimail-Version:%(multimail_version)s 364Auto-Submitted: auto-generated 365""" 366 367COMBINED_INTRO_TEMPLATE ="""\ 368This is an automated email from the git hooks/post-receive script. 369 370%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 371in repository%(repo_shortname)s. 372 373""" 374 375COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE 376 377 378classCommandError(Exception): 379def__init__(self, cmd, retcode): 380 self.cmd = cmd 381 self.retcode = retcode 382Exception.__init__( 383 self, 384'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 385) 386 387 388classConfigurationException(Exception): 389pass 390 391 392# The "git" program (this could be changed to include a full path): 393GIT_EXECUTABLE ='git' 394 395 396# How "git" should be invoked (including global arguments), as a list 397# of words. This variable is usually initialized automatically by 398# read_git_output() via choose_git_command(), but if a value is set 399# here then it will be used unconditionally. 400GIT_CMD =None 401 402 403defchoose_git_command(): 404"""Decide how to invoke git, and record the choice in GIT_CMD.""" 405 406global GIT_CMD 407 408if GIT_CMD is None: 409try: 410# Check to see whether the "-c" option is accepted (it was 411# only added in Git 1.7.2). We don't actually use the 412# output of "git --version", though if we needed more 413# specific version information this would be the place to 414# do it. 415 cmd = [GIT_EXECUTABLE,'-c','foo.bar=baz','--version'] 416read_output(cmd) 417 GIT_CMD = [GIT_EXECUTABLE,'-c','i18n.logoutputencoding=%s'% (ENCODING,)] 418except CommandError: 419 GIT_CMD = [GIT_EXECUTABLE] 420 421 422defread_git_output(args,input=None, keepends=False, **kw): 423"""Read the output of a Git command.""" 424 425if GIT_CMD is None: 426choose_git_command() 427 428returnread_output(GIT_CMD + args,input=input, keepends=keepends, **kw) 429 430 431defread_output(cmd,input=None, keepends=False, **kw): 432ifinput: 433 stdin = subprocess.PIPE 434input=str_to_bytes(input) 435else: 436 stdin =None 437 p = subprocess.Popen( 438 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 439) 440(out, err) = p.communicate(input) 441 out =bytes_to_str(out) 442 retcode = p.wait() 443if retcode: 444raiseCommandError(cmd, retcode) 445if not keepends: 446 out = out.rstrip('\n\r') 447return out 448 449 450defread_git_lines(args, keepends=False, **kw): 451"""Return the lines output by Git command. 452 453 Return as single lines, with newlines stripped off.""" 454 455returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 456 457 458defgit_rev_list_ish(cmd, spec, args=None, **kw): 459"""Common functionality for invoking a 'git rev-list'-like command. 460 461 Parameters: 462 * cmd is the Git command to run, e.g., 'rev-list' or 'log'. 463 * spec is a list of revision arguments to pass to the named 464 command. If None, this function returns an empty list. 465 * args is a list of extra arguments passed to the named command. 466 * All other keyword arguments (if any) are passed to the 467 underlying read_git_lines() function. 468 469 Return the output of the Git command in the form of a list, one 470 entry per output line. 471 """ 472if spec is None: 473return[] 474if args is None: 475 args = [] 476 args = [cmd,'--stdin'] + args 477 spec_stdin =''.join(s +'\n'for s in spec) 478returnread_git_lines(args,input=spec_stdin, **kw) 479 480 481defgit_rev_list(spec, **kw): 482"""Run 'git rev-list' with the given list of revision arguments. 483 484 See git_rev_list_ish() for parameter and return value 485 documentation. 486 """ 487returngit_rev_list_ish('rev-list', spec, **kw) 488 489 490defgit_log(spec, **kw): 491"""Run 'git log' with the given list of revision arguments. 492 493 See git_rev_list_ish() for parameter and return value 494 documentation. 495 """ 496returngit_rev_list_ish('log', spec, **kw) 497 498 499defheader_encode(text, header_name=None): 500"""Encode and line-wrap the value of an email header field.""" 501 502# Convert to unicode, if required. 503if notisinstance(text,unicode): 504 text =unicode(text,'utf-8') 505 506ifis_ascii(text): 507 charset ='ascii' 508else: 509 charset ='utf-8' 510 511returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 512 513 514defaddr_header_encode(text, header_name=None): 515"""Encode and line-wrap the value of an email header field containing 516 email addresses.""" 517 518# Convert to unicode, if required. 519if notisinstance(text,unicode): 520 text =unicode(text,'utf-8') 521 522 text =', '.join( 523formataddr((header_encode(name), emailaddr)) 524for name, emailaddr ingetaddresses([text]) 525) 526 527ifis_ascii(text): 528 charset ='ascii' 529else: 530 charset ='utf-8' 531 532returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 533 534 535classConfig(object): 536def__init__(self, section, git_config=None): 537"""Represent a section of the git configuration. 538 539 If git_config is specified, it is passed to "git config" in 540 the GIT_CONFIG environment variable, meaning that "git config" 541 will read the specified path rather than the Git default 542 config paths.""" 543 544 self.section = section 545if git_config: 546 self.env = os.environ.copy() 547 self.env['GIT_CONFIG'] = git_config 548else: 549 self.env =None 550 551@staticmethod 552def_split(s): 553"""Split NUL-terminated values.""" 554 555 words = s.split('\0') 556assert words[-1] =='' 557return words[:-1] 558 559@staticmethod 560defadd_config_parameters(c): 561"""Add configuration parameters to Git. 562 563 c is either an str or a list of str, each element being of the 564 form 'var=val' or 'var', with the same syntax and meaning as 565 the argument of 'git -c var=val'. 566 """ 567ifisinstance(c,str): 568 c = (c,) 569 parameters = os.environ.get('GIT_CONFIG_PARAMETERS','') 570if parameters: 571 parameters +=' ' 572# git expects GIT_CONFIG_PARAMETERS to be of the form 573# "'name1=value1' 'name2=value2' 'name3=value3'" 574# including everything inside the double quotes (but not the double 575# quotes themselves). Spacing is critical. Also, if a value contains 576# a literal single quote that quote must be represented using the 577# four character sequence: '\'' 578 parameters +=' '.join("'"+ x.replace("'","'\\''") +"'"for x in c) 579 os.environ['GIT_CONFIG_PARAMETERS'] = parameters 580 581defget(self, name, default=None): 582try: 583 values = self._split(read_git_output( 584['config','--get','--null','%s.%s'% (self.section, name)], 585 env=self.env, keepends=True, 586)) 587assertlen(values) ==1 588return values[0] 589except CommandError: 590return default 591 592defget_bool(self, name, default=None): 593try: 594 value =read_git_output( 595['config','--get','--bool','%s.%s'% (self.section, name)], 596 env=self.env, 597) 598except CommandError: 599return default 600return value =='true' 601 602defget_all(self, name, default=None): 603"""Read a (possibly multivalued) setting from the configuration. 604 605 Return the result as a list of values, or default if the name 606 is unset.""" 607 608try: 609return self._split(read_git_output( 610['config','--get-all','--null','%s.%s'% (self.section, name)], 611 env=self.env, keepends=True, 612)) 613except CommandError: 614 t, e, traceback = sys.exc_info() 615if e.retcode ==1: 616# "the section or key is invalid"; i.e., there is no 617# value for the specified key. 618return default 619else: 620raise 621 622defset(self, name, value): 623read_git_output( 624['config','%s.%s'% (self.section, name), value], 625 env=self.env, 626) 627 628defadd(self, name, value): 629read_git_output( 630['config','--add','%s.%s'% (self.section, name), value], 631 env=self.env, 632) 633 634def__contains__(self, name): 635return self.get_all(name, default=None)is not None 636 637# We don't use this method anymore internally, but keep it here in 638# case somebody is calling it from their own code: 639defhas_key(self, name): 640return name in self 641 642defunset_all(self, name): 643try: 644read_git_output( 645['config','--unset-all','%s.%s'% (self.section, name)], 646 env=self.env, 647) 648except CommandError: 649 t, e, traceback = sys.exc_info() 650if e.retcode ==5: 651# The name doesn't exist, which is what we wanted anyway... 652pass 653else: 654raise 655 656defset_recipients(self, name, value): 657 self.unset_all(name) 658for pair ingetaddresses([value]): 659 self.add(name,formataddr(pair)) 660 661 662defgenerate_summaries(*log_args): 663"""Generate a brief summary for each revision requested. 664 665 log_args are strings that will be passed directly to "git log" as 666 revision selectors. Iterate over (sha1_short, subject) for each 667 commit specified by log_args (subject is the first line of the 668 commit message as a string without EOLs).""" 669 670 cmd = [ 671'log','--abbrev','--format=%h%s', 672] +list(log_args) + ['--'] 673for line inread_git_lines(cmd): 674yieldtuple(line.split(' ',1)) 675 676 677deflimit_lines(lines, max_lines): 678for(index, line)inenumerate(lines): 679if index < max_lines: 680yield line 681 682if index >= max_lines: 683yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 684 685 686deflimit_linelength(lines, max_linelength): 687for line in lines: 688# Don't forget that lines always include a trailing newline. 689iflen(line) > max_linelength +1: 690 line = line[:max_linelength -7] +' [...]\n' 691yield line 692 693 694classCommitSet(object): 695"""A (constant) set of object names. 696 697 The set should be initialized with full SHA1 object names. The 698 __contains__() method returns True iff its argument is an 699 abbreviation of any the names in the set.""" 700 701def__init__(self, names): 702 self._names =sorted(names) 703 704def__len__(self): 705returnlen(self._names) 706 707def__contains__(self, sha1_abbrev): 708"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 709 710 i = bisect.bisect_left(self._names, sha1_abbrev) 711return i <len(self)and self._names[i].startswith(sha1_abbrev) 712 713 714classGitObject(object): 715def__init__(self, sha1,type=None): 716if sha1 == ZEROS: 717 self.sha1 = self.type= self.commit_sha1 =None 718else: 719 self.sha1 = sha1 720 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 721 722if self.type=='commit': 723 self.commit_sha1 = self.sha1 724elif self.type=='tag': 725try: 726 self.commit_sha1 =read_git_output( 727['rev-parse','--verify','%s^0'% (self.sha1,)] 728) 729except CommandError: 730# Cannot deref tag to determine commit_sha1 731 self.commit_sha1 =None 732else: 733 self.commit_sha1 =None 734 735 self.short =read_git_output(['rev-parse','--short', sha1]) 736 737defget_summary(self): 738"""Return (sha1_short, subject) for this commit.""" 739 740if not self.sha1: 741raiseValueError('Empty commit has no summary') 742 743returnnext(iter(generate_summaries('--no-walk', self.sha1))) 744 745def__eq__(self, other): 746returnisinstance(other, GitObject)and self.sha1 == other.sha1 747 748def__hash__(self): 749returnhash(self.sha1) 750 751def__nonzero__(self): 752returnbool(self.sha1) 753 754def__bool__(self): 755"""Python 2 backward compatibility""" 756return self.__nonzero__() 757 758def__str__(self): 759return self.sha1 or ZEROS 760 761 762classChange(object): 763"""A Change that has been made to the Git repository. 764 765 Abstract class from which both Revisions and ReferenceChanges are 766 derived. A Change knows how to generate a notification email 767 describing itself.""" 768 769def__init__(self, environment): 770 self.environment = environment 771 self._values =None 772 self._contains_html_diff =False 773 774def_contains_diff(self): 775# We do contain a diff, should it be rendered in HTML? 776if self.environment.commit_email_format =="html": 777 self._contains_html_diff =True 778 779def_compute_values(self): 780"""Return a dictionary{keyword: expansion}for this Change. 781 782 Derived classes overload this method to add more entries to 783 the return value. This method is used internally by 784 get_values(). The return value should always be a new 785 dictionary.""" 786 787 values = self.environment.get_values() 788 fromaddr = self.environment.get_fromaddr(change=self) 789if fromaddr is not None: 790 values['fromaddr'] = fromaddr 791 values['multimail_version'] =get_version() 792return values 793 794# Aliases usable in template strings. Tuple of pairs (destination, 795# source). 796 VALUES_ALIAS = ( 797("id","newrev"), 798) 799 800defget_values(self, **extra_values): 801"""Return a dictionary{keyword: expansion}for this Change. 802 803 Return a dictionary mapping keywords to the values that they 804 should be expanded to for this Change (used when interpolating 805 template strings). If any keyword arguments are supplied, add 806 those to the return value as well. The return value is always 807 a new dictionary.""" 808 809if self._values is None: 810 self._values = self._compute_values() 811 812 values = self._values.copy() 813if extra_values: 814 values.update(extra_values) 815 816for alias, val in self.VALUES_ALIAS: 817 values[alias] = values[val] 818return values 819 820defexpand(self, template, **extra_values): 821"""Expand template. 822 823 Expand the template (which should be a string) using string 824 interpolation of the values for this Change. If any keyword 825 arguments are provided, also include those in the keywords 826 available for interpolation.""" 827 828return template % self.get_values(**extra_values) 829 830defexpand_lines(self, template, html_escape_val=False, **extra_values): 831"""Break template into lines and expand each line.""" 832 833 values = self.get_values(**extra_values) 834if html_escape_val: 835for k in values: 836ifis_string(values[k]): 837 values[k] = cgi.escape(values[k],True) 838for line in template.splitlines(True): 839yield line % values 840 841defexpand_header_lines(self, template, **extra_values): 842"""Break template into lines and expand each line as an RFC 2822 header. 843 844 Encode values and split up lines that are too long. Silently 845 skip lines that contain references to unknown variables.""" 846 847 values = self.get_values(**extra_values) 848if self._contains_html_diff: 849 self._content_type ='html' 850else: 851 self._content_type ='plain' 852 values['contenttype'] = self._content_type 853 854for line in template.splitlines(): 855(name, value) = line.split(': ',1) 856 857try: 858 value = value % values 859exceptKeyError: 860 t, e, traceback = sys.exc_info() 861if DEBUG: 862 self.environment.log_warning( 863'Warning: unknown variable%rin the following line; line skipped:\n' 864'%s\n' 865% (e.args[0], line,) 866) 867else: 868if name.lower()in ADDR_HEADERS: 869 value =addr_header_encode(value, name) 870else: 871 value =header_encode(value, name) 872for splitline in('%s:%s\n'% (name, value)).splitlines(True): 873yield splitline 874 875defgenerate_email_header(self): 876"""Generate the RFC 2822 email headers for this Change, a line at a time. 877 878 The output should not include the trailing blank line.""" 879 880raiseNotImplementedError() 881 882defgenerate_browse_link(self, base_url): 883"""Generate a link to an online repository browser.""" 884returniter(()) 885 886defgenerate_email_intro(self, html_escape_val=False): 887"""Generate the email intro for this Change, a line at a time. 888 889 The output will be used as the standard boilerplate at the top 890 of the email body.""" 891 892raiseNotImplementedError() 893 894defgenerate_email_body(self): 895"""Generate the main part of the email body, a line at a time. 896 897 The text in the body might be truncated after a specified 898 number of lines (see multimailhook.emailmaxlines).""" 899 900raiseNotImplementedError() 901 902defgenerate_email_footer(self, html_escape_val): 903"""Generate the footer of the email, a line at a time. 904 905 The footer is always included, irrespective of 906 multimailhook.emailmaxlines.""" 907 908raiseNotImplementedError() 909 910def_wrap_for_html(self, lines): 911"""Wrap the lines in HTML <pre> tag when using HTML format. 912 913 Escape special HTML characters and add <pre> and </pre> tags around 914 the given lines if we should be generating HTML as indicated by 915 self._contains_html_diff being set to true. 916 """ 917if self._contains_html_diff: 918yield"<pre style='margin:0'>\n" 919 920for line in lines: 921yield cgi.escape(line) 922 923yield'</pre>\n' 924else: 925for line in lines: 926yield line 927 928defgenerate_email(self, push, body_filter=None, extra_header_values={}): 929"""Generate an email describing this change. 930 931 Iterate over the lines (including the header lines) of an 932 email describing this change. If body_filter is not None, 933 then use it to filter the lines that are intended for the 934 email body. 935 936 The extra_header_values field is received as a dict and not as 937 **kwargs, to allow passing other keyword arguments in the 938 future (e.g. passing extra values to generate_email_intro()""" 939 940for line in self.generate_email_header(**extra_header_values): 941yield line 942yield'\n' 943 html_escape_val = (self.environment.html_in_intro and 944 self._contains_html_diff) 945 intro = self.generate_email_intro(html_escape_val) 946if not self.environment.html_in_intro: 947 intro = self._wrap_for_html(intro) 948for line in intro: 949yield line 950 951if self.environment.commitBrowseURL: 952for line in self.generate_browse_link(self.environment.commitBrowseURL): 953yield line 954 955 body = self.generate_email_body(push) 956if body_filter is not None: 957 body =body_filter(body) 958 959 diff_started =False 960if self._contains_html_diff: 961# "white-space: pre" is the default, but we need to 962# specify it again in case the message is viewed in a 963# webmail which wraps it in an element setting white-space 964# to something else (Zimbra does this and sets 965# white-space: pre-line). 966yield'<pre style="white-space: pre; background: #F8F8F8">' 967for line in body: 968if self._contains_html_diff: 969# This is very, very naive. It would be much better to really 970# parse the diff, i.e. look at how many lines do we have in 971# the hunk headers instead of blindly highlighting everything 972# that looks like it might be part of a diff. 973 bgcolor ='' 974 fgcolor ='' 975if line.startswith('--- a/'): 976 diff_started =True 977 bgcolor ='e0e0ff' 978elif line.startswith('diff ')or line.startswith('index '): 979 diff_started =True 980 fgcolor ='808080' 981elif diff_started: 982if line.startswith('+++ '): 983 bgcolor ='e0e0ff' 984elif line.startswith('@@'): 985 bgcolor ='e0e0e0' 986elif line.startswith('+'): 987 bgcolor ='e0ffe0' 988elif line.startswith('-'): 989 bgcolor ='ffe0e0' 990elif line.startswith('commit '): 991 fgcolor ='808000' 992elif line.startswith(' '): 993 fgcolor ='404040' 994 995# Chop the trailing LF, we don't want it inside <pre>. 996 line = cgi.escape(line[:-1]) 997 998if bgcolor or fgcolor: 999 style ='display:block; white-space:pre;'1000if bgcolor:1001 style +='background:#'+ bgcolor +';'1002if fgcolor:1003 style +='color:#'+ fgcolor +';'1004# Use a <span style='display:block> to color the1005# whole line. The newline must be inside the span1006# to display properly both in Firefox and in1007# text-based browser.1008 line ="<span style='%s'>%s\n</span>"% (style, line)1009else:1010 line = line +'\n'10111012yield line1013if self._contains_html_diff:1014yield'</pre>'1015 html_escape_val = (self.environment.html_in_footer and1016 self._contains_html_diff)1017 footer = self.generate_email_footer(html_escape_val)1018if not self.environment.html_in_footer:1019 footer = self._wrap_for_html(footer)1020for line in footer:1021yield line10221023defget_alt_fromaddr(self):1024return None102510261027classRevision(Change):1028"""A Change consisting of a single git commit."""10291030 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')10311032def__init__(self, reference_change, rev, num, tot):1033 Change.__init__(self, reference_change.environment)1034 self.reference_change = reference_change1035 self.rev = rev1036 self.change_type = self.reference_change.change_type1037 self.refname = self.reference_change.refname1038 self.num = num1039 self.tot = tot1040 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1])1041 self.recipients = self.environment.get_revision_recipients(self)10421043 self.cc_recipients =''1044if self.environment.get_scancommitforcc():1045 self.cc_recipients =', '.join(to.strip()for to in self._cc_recipients())1046if self.cc_recipients:1047 self.environment.log_msg(1048'Add%sto CC for%s\n'% (self.cc_recipients, self.rev.sha1))10491050def_cc_recipients(self):1051 cc_recipients = []1052 message =read_git_output(['log','--no-walk','--format=%b', self.rev.sha1])1053 lines = message.strip().split('\n')1054for line in lines:1055 m = re.match(self.CC_RE, line)1056if m:1057 cc_recipients.append(m.group('to'))10581059return cc_recipients10601061def_compute_values(self):1062 values = Change._compute_values(self)10631064 oneline =read_git_output(1065['log','--format=%s','--no-walk', self.rev.sha1]1066)10671068 values['rev'] = self.rev.sha11069 values['rev_short'] = self.rev.short1070 values['change_type'] = self.change_type1071 values['refname'] = self.refname1072 values['newrev'] = self.rev.sha11073 values['short_refname'] = self.reference_change.short_refname1074 values['refname_type'] = self.reference_change.refname_type1075 values['reply_to_msgid'] = self.reference_change.msgid1076 values['num'] = self.num1077 values['tot'] = self.tot1078 values['recipients'] = self.recipients1079if self.cc_recipients:1080 values['cc_recipients'] = self.cc_recipients1081 values['oneline'] = oneline1082 values['author'] = self.author10831084 reply_to = self.environment.get_reply_to_commit(self)1085if reply_to:1086 values['reply_to'] = reply_to10871088return values10891090defgenerate_email_header(self, **extra_values):1091for line in self.expand_header_lines(1092 REVISION_HEADER_TEMPLATE, **extra_values1093):1094yield line10951096defgenerate_browse_link(self, base_url):1097if'%('not in base_url:1098 base_url +='%(id)s'1099 url ="".join(self.expand_lines(base_url))1100if self._content_type =='html':1101for line in self.expand_lines(LINK_HTML_TEMPLATE,1102 html_escape_val=True,1103 browse_url=url):1104yield line1105elif self._content_type =='plain':1106for line in self.expand_lines(LINK_TEXT_TEMPLATE,1107 html_escape_val=False,1108 browse_url=url):1109yield line1110else:1111raiseNotImplementedError("Content-type%sunsupported. Please report it as a bug.")11121113defgenerate_email_intro(self, html_escape_val=False):1114for line in self.expand_lines(REVISION_INTRO_TEMPLATE,1115 html_escape_val=html_escape_val):1116yield line11171118defgenerate_email_body(self, push):1119"""Show this revision."""11201121for line inread_git_lines(1122['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],1123 keepends=True,1124):1125if line.startswith('Date: ')and self.environment.date_substitute:1126yield self.environment.date_substitute + line[len('Date: '):]1127else:1128yield line11291130defgenerate_email_footer(self, html_escape_val):1131return self.expand_lines(REVISION_FOOTER_TEMPLATE,1132 html_escape_val=html_escape_val)11331134defgenerate_email(self, push, body_filter=None, extra_header_values={}):1135 self._contains_diff()1136return Change.generate_email(self, push, body_filter, extra_header_values)11371138defget_alt_fromaddr(self):1139return self.environment.from_commit114011411142classReferenceChange(Change):1143"""A Change to a Git reference.11441145 An abstract class representing a create, update, or delete of a1146 Git reference. Derived classes handle specific types of reference1147 (e.g., tags vs. branches). These classes generate the main1148 reference change email summarizing the reference change and1149 whether it caused any any commits to be added or removed.11501151 ReferenceChange objects are usually created using the static1152 create() method, which has the logic to decide which derived class1153 to instantiate."""11541155 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')11561157@staticmethod1158defcreate(environment, oldrev, newrev, refname):1159"""Return a ReferenceChange object representing the change.11601161 Return an object that represents the type of change that is being1162 made. oldrev and newrev should be SHA1s or ZEROS."""11631164 old =GitObject(oldrev)1165 new =GitObject(newrev)1166 rev = new or old11671168# The revision type tells us what type the commit is, combined with1169# the location of the ref we can decide between1170# - working branch1171# - tracking branch1172# - unannotated tag1173# - annotated tag1174 m = ReferenceChange.REF_RE.match(refname)1175if m:1176 area = m.group('area')1177 short_refname = m.group('shortname')1178else:1179 area =''1180 short_refname = refname11811182if rev.type=='tag':1183# Annotated tag:1184 klass = AnnotatedTagChange1185elif rev.type=='commit':1186if area =='tags':1187# Non-annotated tag:1188 klass = NonAnnotatedTagChange1189elif area =='heads':1190# Branch:1191 klass = BranchChange1192elif area =='remotes':1193# Tracking branch:1194 environment.log_warning(1195'*** Push-update of tracking branch%r\n'1196'*** - incomplete email generated.\n'1197% (refname,)1198)1199 klass = OtherReferenceChange1200else:1201# Some other reference namespace:1202 environment.log_warning(1203'*** Push-update of strange reference%r\n'1204'*** - incomplete email generated.\n'1205% (refname,)1206)1207 klass = OtherReferenceChange1208else:1209# Anything else (is there anything else?)1210 environment.log_warning(1211'*** Unknown type of update to%r(%s)\n'1212'*** - incomplete email generated.\n'1213% (refname, rev.type,)1214)1215 klass = OtherReferenceChange12161217returnklass(1218 environment,1219 refname=refname, short_refname=short_refname,1220 old=old, new=new, rev=rev,1221)12221223def__init__(self, environment, refname, short_refname, old, new, rev):1224 Change.__init__(self, environment)1225 self.change_type = {1226(False,True):'create',1227(True,True):'update',1228(True,False):'delete',1229}[bool(old),bool(new)]1230 self.refname = refname1231 self.short_refname = short_refname1232 self.old = old1233 self.new = new1234 self.rev = rev1235 self.msgid =make_msgid()1236 self.diffopts = environment.diffopts1237 self.graphopts = environment.graphopts1238 self.logopts = environment.logopts1239 self.commitlogopts = environment.commitlogopts1240 self.showgraph = environment.refchange_showgraph1241 self.showlog = environment.refchange_showlog12421243 self.header_template = REFCHANGE_HEADER_TEMPLATE1244 self.intro_template = REFCHANGE_INTRO_TEMPLATE1245 self.footer_template = FOOTER_TEMPLATE12461247def_compute_values(self):1248 values = Change._compute_values(self)12491250 values['change_type'] = self.change_type1251 values['refname_type'] = self.refname_type1252 values['refname'] = self.refname1253 values['short_refname'] = self.short_refname1254 values['msgid'] = self.msgid1255 values['recipients'] = self.recipients1256 values['oldrev'] =str(self.old)1257 values['oldrev_short'] = self.old.short1258 values['newrev'] =str(self.new)1259 values['newrev_short'] = self.new.short12601261if self.old:1262 values['oldrev_type'] = self.old.type1263if self.new:1264 values['newrev_type'] = self.new.type12651266 reply_to = self.environment.get_reply_to_refchange(self)1267if reply_to:1268 values['reply_to'] = reply_to12691270return values12711272defsend_single_combined_email(self, known_added_sha1s):1273"""Determine if a combined refchange/revision email should be sent12741275 If there is only a single new (non-merge) commit added by a1276 change, it is useful to combine the ReferenceChange and1277 Revision emails into one. In such a case, return the single1278 revision; otherwise, return None.12791280 This method is overridden in BranchChange."""12811282return None12831284defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1285"""Generate an email describing this change AND specified revision.12861287 Iterate over the lines (including the header lines) of an1288 email describing this change. If body_filter is not None,1289 then use it to filter the lines that are intended for the1290 email body.12911292 The extra_header_values field is received as a dict and not as1293 **kwargs, to allow passing other keyword arguments in the1294 future (e.g. passing extra values to generate_email_intro()12951296 This method is overridden in BranchChange."""12971298raiseNotImplementedError12991300defget_subject(self):1301 template = {1302'create': REF_CREATED_SUBJECT_TEMPLATE,1303'update': REF_UPDATED_SUBJECT_TEMPLATE,1304'delete': REF_DELETED_SUBJECT_TEMPLATE,1305}[self.change_type]1306return self.expand(template)13071308defgenerate_email_header(self, **extra_values):1309if'subject'not in extra_values:1310 extra_values['subject'] = self.get_subject()13111312for line in self.expand_header_lines(1313 self.header_template, **extra_values1314):1315yield line13161317defgenerate_email_intro(self, html_escape_val=False):1318for line in self.expand_lines(self.intro_template,1319 html_escape_val=html_escape_val):1320yield line13211322defgenerate_email_body(self, push):1323"""Call the appropriate body-generation routine.13241325 Call one of generate_create_summary() /1326 generate_update_summary() / generate_delete_summary()."""13271328 change_summary = {1329'create': self.generate_create_summary,1330'delete': self.generate_delete_summary,1331'update': self.generate_update_summary,1332}[self.change_type](push)1333for line in change_summary:1334yield line13351336for line in self.generate_revision_change_summary(push):1337yield line13381339defgenerate_email_footer(self, html_escape_val):1340return self.expand_lines(self.footer_template,1341 html_escape_val=html_escape_val)13421343defgenerate_revision_change_graph(self, push):1344if self.showgraph:1345 args = ['--graph'] + self.graphopts1346for newold in('new','old'):1347 has_newold =False1348 spec = push.get_commits_spec(newold, self)1349for line ingit_log(spec, args=args, keepends=True):1350if not has_newold:1351 has_newold =True1352yield'\n'1353yield'Graph of%scommits:\n\n'% (1354 {'new': 'new', 'old': 'discarded'}[newold],)1355yield' '+ line1356if has_newold:1357yield'\n'13581359defgenerate_revision_change_log(self, new_commits_list):1360if self.showlog:1361yield'\n'1362yield'Detailed log of new commits:\n\n'1363for line inread_git_lines(1364['log','--no-walk'] +1365 self.logopts +1366 new_commits_list +1367['--'],1368 keepends=True,1369):1370yield line13711372defgenerate_new_revision_summary(self, tot, new_commits_list, push):1373for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):1374yield line1375for line in self.generate_revision_change_graph(push):1376yield line1377for line in self.generate_revision_change_log(new_commits_list):1378yield line13791380defgenerate_revision_change_summary(self, push):1381"""Generate a summary of the revisions added/removed by this change."""13821383if self.new.commit_sha1 and not self.old.commit_sha1:1384# A new reference was created. List the new revisions1385# brought by the new reference (i.e., those revisions that1386# were not in the repository before this reference1387# change).1388 sha1s =list(push.get_new_commits(self))1389 sha1s.reverse()1390 tot =len(sha1s)1391 new_revisions = [1392Revision(self,GitObject(sha1), num=i +1, tot=tot)1393for(i, sha1)inenumerate(sha1s)1394]13951396if new_revisions:1397yield self.expand('This%(refname_type)sincludes the following new commits:\n')1398yield'\n'1399for r in new_revisions:1400(sha1, subject) = r.rev.get_summary()1401yield r.expand(1402 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,1403)1404yield'\n'1405for line in self.generate_new_revision_summary(1406 tot, [r.rev.sha1 for r in new_revisions], push):1407yield line1408else:1409for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1410yield line14111412elif self.new.commit_sha1 and self.old.commit_sha1:1413# A reference was changed to point at a different commit.1414# List the revisions that were removed and/or added *from1415# that reference* by this reference change, along with a1416# diff between the trees for its old and new values.14171418# List of the revisions that were added to the branch by1419# this update. Note this list can include revisions that1420# have already had notification emails; we want such1421# revisions in the summary even though we will not send1422# new notification emails for them.1423 adds =list(generate_summaries(1424'--topo-order','--reverse','%s..%s'1425% (self.old.commit_sha1, self.new.commit_sha1,)1426))14271428# List of the revisions that were removed from the branch1429# by this update. This will be empty except for1430# non-fast-forward updates.1431 discards =list(generate_summaries(1432'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,)1433))14341435if adds:1436 new_commits_list = push.get_new_commits(self)1437else:1438 new_commits_list = []1439 new_commits =CommitSet(new_commits_list)14401441if discards:1442 discarded_commits =CommitSet(push.get_discarded_commits(self))1443else:1444 discarded_commits =CommitSet([])14451446if discards and adds:1447for(sha1, subject)in discards:1448if sha1 in discarded_commits:1449 action ='discards'1450else:1451 action ='omits'1452yield self.expand(1453 BRIEF_SUMMARY_TEMPLATE, action=action,1454 rev_short=sha1, text=subject,1455)1456for(sha1, subject)in adds:1457if sha1 in new_commits:1458 action ='new'1459else:1460 action ='adds'1461yield self.expand(1462 BRIEF_SUMMARY_TEMPLATE, action=action,1463 rev_short=sha1, text=subject,1464)1465yield'\n'1466for line in self.expand_lines(NON_FF_TEMPLATE):1467yield line14681469elif discards:1470for(sha1, subject)in discards:1471if sha1 in discarded_commits:1472 action ='discards'1473else:1474 action ='omits'1475yield self.expand(1476 BRIEF_SUMMARY_TEMPLATE, action=action,1477 rev_short=sha1, text=subject,1478)1479yield'\n'1480for line in self.expand_lines(REWIND_ONLY_TEMPLATE):1481yield line14821483elif adds:1484(sha1, subject) = self.old.get_summary()1485yield self.expand(1486 BRIEF_SUMMARY_TEMPLATE, action='from',1487 rev_short=sha1, text=subject,1488)1489for(sha1, subject)in adds:1490if sha1 in new_commits:1491 action ='new'1492else:1493 action ='adds'1494yield self.expand(1495 BRIEF_SUMMARY_TEMPLATE, action=action,1496 rev_short=sha1, text=subject,1497)14981499yield'\n'15001501if new_commits:1502for line in self.generate_new_revision_summary(1503len(new_commits), new_commits_list, push):1504yield line1505else:1506for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1507yield line1508for line in self.generate_revision_change_graph(push):1509yield line15101511# The diffstat is shown from the old revision to the new1512# revision. This is to show the truth of what happened in1513# this change. There's no point showing the stat from the1514# base to the new revision because the base is effectively a1515# random revision at this point - the user will be interested1516# in what this revision changed - including the undoing of1517# previous revisions in the case of non-fast-forward updates.1518yield'\n'1519yield'Summary of changes:\n'1520for line inread_git_lines(1521['diff-tree'] +1522 self.diffopts +1523['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1524 keepends=True,1525):1526yield line15271528elif self.old.commit_sha1 and not self.new.commit_sha1:1529# A reference was deleted. List the revisions that were1530# removed from the repository by this reference change.15311532 sha1s =list(push.get_discarded_commits(self))1533 tot =len(sha1s)1534 discarded_revisions = [1535Revision(self,GitObject(sha1), num=i +1, tot=tot)1536for(i, sha1)inenumerate(sha1s)1537]15381539if discarded_revisions:1540for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1541yield line1542yield'\n'1543for r in discarded_revisions:1544(sha1, subject) = r.rev.get_summary()1545yield r.expand(1546 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,1547)1548for line in self.generate_revision_change_graph(push):1549yield line1550else:1551for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1552yield line15531554elif not self.old.commit_sha1 and not self.new.commit_sha1:1555for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1556yield line15571558defgenerate_create_summary(self, push):1559"""Called for the creation of a reference."""15601561# This is a new reference and so oldrev is not valid1562(sha1, subject) = self.new.get_summary()1563yield self.expand(1564 BRIEF_SUMMARY_TEMPLATE, action='at',1565 rev_short=sha1, text=subject,1566)1567yield'\n'15681569defgenerate_update_summary(self, push):1570"""Called for the change of a pre-existing branch."""15711572returniter([])15731574defgenerate_delete_summary(self, push):1575"""Called for the deletion of any type of reference."""15761577(sha1, subject) = self.old.get_summary()1578yield self.expand(1579 BRIEF_SUMMARY_TEMPLATE, action='was',1580 rev_short=sha1, text=subject,1581)1582yield'\n'15831584defget_alt_fromaddr(self):1585return self.environment.from_refchange158615871588classBranchChange(ReferenceChange):1589 refname_type ='branch'15901591def__init__(self, environment, refname, short_refname, old, new, rev):1592 ReferenceChange.__init__(1593 self, environment,1594 refname=refname, short_refname=short_refname,1595 old=old, new=new, rev=rev,1596)1597 self.recipients = environment.get_refchange_recipients(self)1598 self._single_revision =None15991600defsend_single_combined_email(self, known_added_sha1s):1601if not self.environment.combine_when_single_commit:1602return None16031604# In the sadly-all-too-frequent usecase of people pushing only1605# one of their commits at a time to a repository, users feel1606# the reference change summary emails are noise rather than1607# important signal. This is because, in this particular1608# usecase, there is a reference change summary email for each1609# new commit, and all these summaries do is point out that1610# there is one new commit (which can readily be inferred by1611# the existence of the individual revision email that is also1612# sent). In such cases, our users prefer there to be a combined1613# reference change summary/new revision email.1614#1615# So, if the change is an update and it doesn't discard any1616# commits, and it adds exactly one non-merge commit (gerrit1617# forces a workflow where every commit is individually merged1618# and the git-multimail hook fired off for just this one1619# change), then we send a combined refchange/revision email.1620try:1621# If this change is a reference update that doesn't discard1622# any commits...1623if self.change_type !='update':1624return None16251626ifread_git_lines(1627['merge-base', self.old.sha1, self.new.sha1]1628) != [self.old.sha1]:1629return None16301631# Check if this update introduced exactly one non-merge1632# commit:16331634defsplit_line(line):1635"""Split line into (sha1, [parent,...])."""16361637 words = line.split()1638return(words[0], words[1:])16391640# Get the new commits introduced by the push as a list of1641# (sha1, [parent,...])1642 new_commits = [1643split_line(line)1644for line inread_git_lines(1645[1646'log','-3','--format=%H %P',1647'%s..%s'% (self.old.sha1, self.new.sha1),1648]1649)1650]16511652if not new_commits:1653return None16541655# If the newest commit is a merge, save it for a later check1656# but otherwise ignore it1657 merge =None1658 tot =len(new_commits)1659iflen(new_commits[0][1]) >1:1660 merge = new_commits[0][0]1661del new_commits[0]16621663# Our primary check: we can't combine if more than one commit1664# is introduced. We also currently only combine if the new1665# commit is a non-merge commit, though it may make sense to1666# combine if it is a merge as well.1667if not(1668len(new_commits) ==1and1669len(new_commits[0][1]) ==1and1670 new_commits[0][0]in known_added_sha1s1671):1672return None16731674# We do not want to combine revision and refchange emails if1675# those go to separate locations.1676 rev =Revision(self,GitObject(new_commits[0][0]),1, tot)1677if rev.recipients != self.recipients:1678return None16791680# We ignored the newest commit if it was just a merge of the one1681# commit being introduced. But we don't want to ignore that1682# merge commit it it involved conflict resolutions. Check that.1683if merge and merge !=read_git_output(['diff-tree','--cc', merge]):1684return None16851686# We can combine the refchange and one new revision emails1687# into one. Return the Revision that a combined email should1688# be sent about.1689return rev1690except CommandError:1691# Cannot determine number of commits in old..new or new..old;1692# don't combine reference/revision emails:1693return None16941695defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1696 values = revision.get_values()1697if extra_header_values:1698 values.update(extra_header_values)1699if'subject'not in extra_header_values:1700 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)17011702 self._single_revision = revision1703 self._contains_diff()1704 self.header_template = COMBINED_HEADER_TEMPLATE1705 self.intro_template = COMBINED_INTRO_TEMPLATE1706 self.footer_template = COMBINED_FOOTER_TEMPLATE17071708defrevision_gen_link(base_url):1709# revision is used only to generate the body, and1710# _content_type is set while generating headers. Get it1711# from the BranchChange object.1712 revision._content_type = self._content_type1713return revision.generate_browse_link(base_url)1714 self.generate_browse_link = revision_gen_link1715for line in self.generate_email(push, body_filter, values):1716yield line17171718defgenerate_email_body(self, push):1719'''Call the appropriate body generation routine.17201721 If this is a combined refchange/revision email, the special logic1722 for handling this combined email comes from this function. For1723 other cases, we just use the normal handling.'''17241725# If self._single_revision isn't set; don't override1726if not self._single_revision:1727for line insuper(BranchChange, self).generate_email_body(push):1728yield line1729return17301731# This is a combined refchange/revision email; we first provide1732# some info from the refchange portion, and then call the revision1733# generate_email_body function to handle the revision portion.1734 adds =list(generate_summaries(1735'--topo-order','--reverse','%s..%s'1736% (self.old.commit_sha1, self.new.commit_sha1,)1737))17381739yield self.expand("The following commit(s) were added to%(refname)sby this push:\n")1740for(sha1, subject)in adds:1741yield self.expand(1742 BRIEF_SUMMARY_TEMPLATE, action='new',1743 rev_short=sha1, text=subject,1744)17451746yield self._single_revision.rev.short +" is described below\n"1747yield'\n'17481749for line in self._single_revision.generate_email_body(push):1750yield line175117521753classAnnotatedTagChange(ReferenceChange):1754 refname_type ='annotated tag'17551756def__init__(self, environment, refname, short_refname, old, new, rev):1757 ReferenceChange.__init__(1758 self, environment,1759 refname=refname, short_refname=short_refname,1760 old=old, new=new, rev=rev,1761)1762 self.recipients = environment.get_announce_recipients(self)1763 self.show_shortlog = environment.announce_show_shortlog17641765 ANNOTATED_TAG_FORMAT = (1766'%(*objectname)\n'1767'%(*objecttype)\n'1768'%(taggername)\n'1769'%(taggerdate)'1770)17711772defdescribe_tag(self, push):1773"""Describe the new value of an annotated tag."""17741775# Use git for-each-ref to pull out the individual fields from1776# the tag1777[tagobject, tagtype, tagger, tagged] =read_git_lines(1778['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1779)17801781yield self.expand(1782 BRIEF_SUMMARY_TEMPLATE, action='tagging',1783 rev_short=tagobject, text='(%s)'% (tagtype,),1784)1785if tagtype =='commit':1786# If the tagged object is a commit, then we assume this is a1787# release, and so we calculate which tag this tag is1788# replacing1789try:1790 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1791except CommandError:1792 prevtag =None1793if prevtag:1794yield' replaces%s\n'% (prevtag,)1795else:1796 prevtag =None1797yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)17981799yield' tagged by%s\n'% (tagger,)1800yield' on%s\n'% (tagged,)1801yield'\n'18021803# Show the content of the tag message; this might contain a1804# change log or release notes so is worth displaying.1805yield LOGBEGIN1806 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1807 contents = contents[contents.index('\n') +1:]1808if contents and contents[-1][-1:] !='\n':1809 contents.append('\n')1810for line in contents:1811yield line18121813if self.show_shortlog and tagtype =='commit':1814# Only commit tags make sense to have rev-list operations1815# performed on them1816yield'\n'1817if prevtag:1818# Show changes since the previous release1819 revlist =read_git_output(1820['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1821 keepends=True,1822)1823else:1824# No previous tag, show all the changes since time1825# began1826 revlist =read_git_output(1827['rev-list','--pretty=short','%s'% (self.new,)],1828 keepends=True,1829)1830for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1831yield line18321833yield LOGEND1834yield'\n'18351836defgenerate_create_summary(self, push):1837"""Called for the creation of an annotated tag."""18381839for line in self.expand_lines(TAG_CREATED_TEMPLATE):1840yield line18411842for line in self.describe_tag(push):1843yield line18441845defgenerate_update_summary(self, push):1846"""Called for the update of an annotated tag.18471848 This is probably a rare event and may not even be allowed."""18491850for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1851yield line18521853for line in self.describe_tag(push):1854yield line18551856defgenerate_delete_summary(self, push):1857"""Called when a non-annotated reference is updated."""18581859for line in self.expand_lines(TAG_DELETED_TEMPLATE):1860yield line18611862yield self.expand(' tag was%(oldrev_short)s\n')1863yield'\n'186418651866classNonAnnotatedTagChange(ReferenceChange):1867 refname_type ='tag'18681869def__init__(self, environment, refname, short_refname, old, new, rev):1870 ReferenceChange.__init__(1871 self, environment,1872 refname=refname, short_refname=short_refname,1873 old=old, new=new, rev=rev,1874)1875 self.recipients = environment.get_refchange_recipients(self)18761877defgenerate_create_summary(self, push):1878"""Called for the creation of an annotated tag."""18791880for line in self.expand_lines(TAG_CREATED_TEMPLATE):1881yield line18821883defgenerate_update_summary(self, push):1884"""Called when a non-annotated reference is updated."""18851886for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1887yield line18881889defgenerate_delete_summary(self, push):1890"""Called when a non-annotated reference is updated."""18911892for line in self.expand_lines(TAG_DELETED_TEMPLATE):1893yield line18941895for line in ReferenceChange.generate_delete_summary(self, push):1896yield line189718981899classOtherReferenceChange(ReferenceChange):1900 refname_type ='reference'19011902def__init__(self, environment, refname, short_refname, old, new, rev):1903# We use the full refname as short_refname, because otherwise1904# the full name of the reference would not be obvious from the1905# text of the email.1906 ReferenceChange.__init__(1907 self, environment,1908 refname=refname, short_refname=refname,1909 old=old, new=new, rev=rev,1910)1911 self.recipients = environment.get_refchange_recipients(self)191219131914classMailer(object):1915"""An object that can send emails."""19161917defsend(self, lines, to_addrs):1918"""Send an email consisting of lines.19191920 lines must be an iterable over the lines constituting the1921 header and body of the email. to_addrs is a list of recipient1922 addresses (can be needed even if lines already contains a1923 "To:" field). It can be either a string (comma-separated list1924 of email addresses) or a Python list of individual email1925 addresses.19261927 """19281929raiseNotImplementedError()193019311932classSendMailer(Mailer):1933"""Send emails using 'sendmail -oi -t'."""19341935 SENDMAIL_CANDIDATES = [1936'/usr/sbin/sendmail',1937'/usr/lib/sendmail',1938]19391940@staticmethod1941deffind_sendmail():1942for path in SendMailer.SENDMAIL_CANDIDATES:1943if os.access(path, os.X_OK):1944return path1945else:1946raiseConfigurationException(1947'No sendmail executable found. '1948'Try setting multimailhook.sendmailCommand.'1949)19501951def__init__(self, command=None, envelopesender=None):1952"""Construct a SendMailer instance.19531954 command should be the command and arguments used to invoke1955 sendmail, as a list of strings. If an envelopesender is1956 provided, it will also be passed to the command, via '-f1957 envelopesender'."""19581959if command:1960 self.command = command[:]1961else:1962 self.command = [self.find_sendmail(),'-oi','-t']19631964if envelopesender:1965 self.command.extend(['-f', envelopesender])19661967defsend(self, lines, to_addrs):1968try:1969 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)1970exceptOSError:1971 sys.stderr.write(1972'*** Cannot execute command:%s\n'%' '.join(self.command) +1973'***%s\n'% sys.exc_info()[1] +1974'*** Try setting multimailhook.mailer to "smtp"\n'+1975'*** to send emails without using the sendmail command.\n'1976)1977 sys.exit(1)1978try:1979 lines = (str_to_bytes(line)for line in lines)1980 p.stdin.writelines(lines)1981exceptException:1982 sys.stderr.write(1983'*** Error while generating commit email\n'1984'*** - mail sending aborted.\n'1985)1986try:1987# subprocess.terminate() is not available in Python 2.41988 p.terminate()1989exceptAttributeError:1990pass1991raise1992else:1993 p.stdin.close()1994 retcode = p.wait()1995if retcode:1996raiseCommandError(self.command, retcode)199719981999classSMTPMailer(Mailer):2000"""Send emails using Python's smtplib."""20012002def__init__(self, envelopesender, smtpserver,2003 smtpservertimeout=10.0, smtpserverdebuglevel=0,2004 smtpencryption='none',2005 smtpuser='', smtppass='',2006 smtpcacerts=''2007):2008if not envelopesender:2009 sys.stderr.write(2010'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'2011'please set either multimailhook.envelopeSender or user.email\n'2012)2013 sys.exit(1)2014if smtpencryption =='ssl'and not(smtpuser and smtppass):2015raiseConfigurationException(2016'Cannot use SMTPMailer with security option ssl '2017'without options username and password.'2018)2019 self.envelopesender = envelopesender2020 self.smtpserver = smtpserver2021 self.smtpservertimeout = smtpservertimeout2022 self.smtpserverdebuglevel = smtpserverdebuglevel2023 self.security = smtpencryption2024 self.username = smtpuser2025 self.password = smtppass2026 self.smtpcacerts = smtpcacerts2027try:2028defcall(klass, server, timeout):2029try:2030returnklass(server, timeout=timeout)2031exceptTypeError:2032# Old Python versions do not have timeout= argument.2033returnklass(server)2034if self.security =='none':2035 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2036elif self.security =='ssl':2037if self.smtpcacerts:2038raise smtplib.SMTPException(2039"Checking certificate is not supported for ssl, prefer starttls"2040)2041 self.smtp =call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)2042elif self.security =='tls':2043if'ssl'not in sys.modules:2044 sys.stderr.write(2045'*** Your Python version does not have the ssl library installed\n'2046'*** smtpEncryption=tls is not available.\n'2047'*** Either upgrade Python to 2.6 or later\n'2048' or use git_multimail.py version 1.2.\n')2049if':'not in self.smtpserver:2050 self.smtpserver +=':587'# default port for TLS2051 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2052# start: ehlo + starttls2053# equivalent to2054# self.smtp.ehlo()2055# self.smtp.starttls()2056# with acces to the ssl layer2057 self.smtp.ehlo()2058if not self.smtp.has_extn("starttls"):2059raise smtplib.SMTPException("STARTTLS extension not supported by server")2060 resp, reply = self.smtp.docmd("STARTTLS")2061if resp !=220:2062raise smtplib.SMTPException("Wrong answer to the STARTTLS command")2063if self.smtpcacerts:2064 self.smtp.sock = ssl.wrap_socket(2065 self.smtp.sock,2066 ca_certs=self.smtpcacerts,2067 cert_reqs=ssl.CERT_REQUIRED2068)2069else:2070 self.smtp.sock = ssl.wrap_socket(2071 self.smtp.sock,2072 cert_reqs=ssl.CERT_NONE2073)2074 sys.stderr.write(2075'*** Warning, the server certificat is not verified (smtp) ***\n'2076'*** set the option smtpCACerts ***\n'2077)2078if nothasattr(self.smtp.sock,"read"):2079# using httplib.FakeSocket with Python 2.5.x or earlier2080 self.smtp.sock.read = self.smtp.sock.recv2081 self.smtp.file= smtplib.SSLFakeFile(self.smtp.sock)2082 self.smtp.helo_resp =None2083 self.smtp.ehlo_resp =None2084 self.smtp.esmtp_features = {}2085 self.smtp.does_esmtp =02086# end: ehlo + starttls2087 self.smtp.ehlo()2088else:2089 sys.stdout.write('*** Error: Control reached an invalid option. ***')2090 sys.exit(1)2091if self.smtpserverdebuglevel >0:2092 sys.stdout.write(2093"*** Setting debug on for SMTP server connection (%s) ***\n"2094% self.smtpserverdebuglevel)2095 self.smtp.set_debuglevel(self.smtpserverdebuglevel)2096exceptException:2097 sys.stderr.write(2098'*** Error establishing SMTP connection to%s***\n'2099% self.smtpserver)2100 sys.stderr.write('***%s\n'% sys.exc_info()[1])2101 sys.exit(1)21022103def__del__(self):2104ifhasattr(self,'smtp'):2105 self.smtp.quit()2106del self.smtp21072108defsend(self, lines, to_addrs):2109try:2110if self.username or self.password:2111 self.smtp.login(self.username, self.password)2112 msg =''.join(lines)2113# turn comma-separated list into Python list if needed.2114ifis_string(to_addrs):2115 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]2116 self.smtp.sendmail(self.envelopesender, to_addrs, msg)2117except smtplib.SMTPResponseException:2118 sys.stderr.write('*** Error sending email ***\n')2119 err = sys.exc_info()[1]2120 sys.stderr.write('*** Error%d:%s\n'% (err.smtp_code,2121bytes_to_str(err.smtp_error)))2122try:2123 smtp = self.smtp2124# delete the field before quit() so that in case of2125# error, self.smtp is deleted anyway.2126del self.smtp2127 smtp.quit()2128except:2129 sys.stderr.write('*** Error closing the SMTP connection ***\n')2130 sys.stderr.write('*** Exiting anyway ... ***\n')2131 sys.stderr.write('***%s\n'% sys.exc_info()[1])2132 sys.exit(1)213321342135classOutputMailer(Mailer):2136"""Write emails to an output stream, bracketed by lines of '=' characters.21372138 This is intended for debugging purposes."""21392140 SEPARATOR ='='*75+'\n'21412142def__init__(self, f):2143 self.f = f21442145defsend(self, lines, to_addrs):2146write_str(self.f, self.SEPARATOR)2147for line in lines:2148write_str(self.f, line)2149write_str(self.f, self.SEPARATOR)215021512152defget_git_dir():2153"""Determine GIT_DIR.21542155 Determine GIT_DIR either from the GIT_DIR environment variable or2156 from the working directory, using Git's usual rules."""21572158try:2159returnread_git_output(['rev-parse','--git-dir'])2160except CommandError:2161 sys.stderr.write('fatal: git_multimail: not in a git directory\n')2162 sys.exit(1)216321642165classEnvironment(object):2166"""Describes the environment in which the push is occurring.21672168 An Environment object encapsulates information about the local2169 environment. For example, it knows how to determine:21702171 * the name of the repository to which the push occurred21722173 * what user did the push21742175 * what users want to be informed about various types of changes.21762177 An Environment object is expected to have the following methods:21782179 get_repo_shortname()21802181 Return a short name for the repository, for display2182 purposes.21832184 get_repo_path()21852186 Return the absolute path to the Git repository.21872188 get_emailprefix()21892190 Return a string that will be prefixed to every email's2191 subject.21922193 get_pusher()21942195 Return the username of the person who pushed the changes.2196 This value is used in the email body to indicate who2197 pushed the change.21982199 get_pusher_email() (may return None)22002201 Return the email address of the person who pushed the2202 changes. The value should be a single RFC 2822 email2203 address as a string; e.g., "Joe User <user@example.com>"2204 if available, otherwise "user@example.com". If set, the2205 value is used as the Reply-To address for refchange2206 emails. If it is impossible to determine the pusher's2207 email, this attribute should be set to None (in which case2208 no Reply-To header will be output).22092210 get_sender()22112212 Return the address to be used as the 'From' email address2213 in the email envelope.22142215 get_fromaddr(change=None)22162217 Return the 'From' email address used in the email 'From:'2218 headers. If the change is known when this function is2219 called, it is passed in as the 'change' parameter. (May2220 be a full RFC 2822 email address like 'Joe User2221 <user@example.com>'.)22222223 get_administrator()22242225 Return the name and/or email of the repository2226 administrator. This value is used in the footer as the2227 person to whom requests to be removed from the2228 notification list should be sent. Ideally, it should2229 include a valid email address.22302231 get_reply_to_refchange()2232 get_reply_to_commit()22332234 Return the address to use in the email "Reply-To" header,2235 as a string. These can be an RFC 2822 email address, or2236 None to omit the "Reply-To" header.2237 get_reply_to_refchange() is used for refchange emails;2238 get_reply_to_commit() is used for individual commit2239 emails.22402241 get_ref_filter_regex()22422243 Return a tuple -- a compiled regex, and a boolean indicating2244 whether the regex picks refs to include (if False, the regex2245 matches on refs to exclude).22462247 get_default_ref_ignore_regex()22482249 Return a regex that should be ignored for both what emails2250 to send and when computing what commits are considered new2251 to the repository. Default is "^refs/notes/".22522253 They should also define the following attributes:22542255 announce_show_shortlog (bool)22562257 True iff announce emails should include a shortlog.22582259 commit_email_format (string)22602261 If "html", generate commit emails in HTML instead of plain text2262 used by default.22632264 html_in_intro (bool)2265 html_in_footer (bool)22662267 When generating HTML emails, the introduction (respectively,2268 the footer) will be HTML-escaped iff html_in_intro (respectively,2269 the footer) is true. When false, only the values used to expand2270 the template are escaped.22712272 refchange_showgraph (bool)22732274 True iff refchanges emails should include a detailed graph.22752276 refchange_showlog (bool)22772278 True iff refchanges emails should include a detailed log.22792280 diffopts (list of strings)22812282 The options that should be passed to 'git diff' for the2283 summary email. The value should be a list of strings2284 representing words to be passed to the command.22852286 graphopts (list of strings)22872288 Analogous to diffopts, but contains options passed to2289 'git log --graph' when generating the detailed graph for2290 a set of commits (see refchange_showgraph)22912292 logopts (list of strings)22932294 Analogous to diffopts, but contains options passed to2295 'git log' when generating the detailed log for a set of2296 commits (see refchange_showlog)22972298 commitlogopts (list of strings)22992300 The options that should be passed to 'git log' for each2301 commit mail. The value should be a list of strings2302 representing words to be passed to the command.23032304 date_substitute (string)23052306 String to be used in substitution for 'Date:' at start of2307 line in the output of 'git log'.23082309 quiet (bool)2310 On success do not write to stderr23112312 stdout (bool)2313 Write email to stdout rather than emailing. Useful for debugging23142315 combine_when_single_commit (bool)23162317 True if a combined email should be produced when a single2318 new commit is pushed to a branch, False otherwise.23192320 from_refchange, from_commit (strings)23212322 Addresses to use for the From: field for refchange emails2323 and commit emails respectively. Set from2324 multimailhook.fromRefchange and multimailhook.fromCommit2325 by ConfigEnvironmentMixin.23262327 """23282329 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')23302331def__init__(self, osenv=None):2332 self.osenv = osenv or os.environ2333 self.announce_show_shortlog =False2334 self.commit_email_format ="text"2335 self.html_in_intro =False2336 self.html_in_footer =False2337 self.commitBrowseURL =None2338 self.maxcommitemails =5002339 self.diffopts = ['--stat','--summary','--find-copies-harder']2340 self.graphopts = ['--oneline','--decorate']2341 self.logopts = []2342 self.refchange_showgraph =False2343 self.refchange_showlog =False2344 self.commitlogopts = ['-C','--stat','-p','--cc']2345 self.date_substitute ='AuthorDate: '2346 self.quiet =False2347 self.stdout =False2348 self.combine_when_single_commit =True23492350 self.COMPUTED_KEYS = [2351'administrator',2352'charset',2353'emailprefix',2354'pusher',2355'pusher_email',2356'repo_path',2357'repo_shortname',2358'sender',2359]23602361 self._values =None23622363defget_repo_shortname(self):2364"""Use the last part of the repo path, with ".git" stripped off if present."""23652366 basename = os.path.basename(os.path.abspath(self.get_repo_path()))2367 m = self.REPO_NAME_RE.match(basename)2368if m:2369return m.group('name')2370else:2371return basename23722373defget_pusher(self):2374raiseNotImplementedError()23752376defget_pusher_email(self):2377return None23782379defget_fromaddr(self, change=None):2380 config =Config('user')2381 fromname = config.get('name', default='')2382 fromemail = config.get('email', default='')2383if fromemail:2384returnformataddr([fromname, fromemail])2385return self.get_sender()23862387defget_administrator(self):2388return'the administrator of this repository'23892390defget_emailprefix(self):2391return''23922393defget_repo_path(self):2394ifread_git_output(['rev-parse','--is-bare-repository']) =='true':2395 path =get_git_dir()2396else:2397 path =read_git_output(['rev-parse','--show-toplevel'])2398return os.path.abspath(path)23992400defget_charset(self):2401return CHARSET24022403defget_values(self):2404"""Return a dictionary{keyword: expansion}for this Environment.24052406 This method is called by Change._compute_values(). The keys2407 in the returned dictionary are available to be used in any of2408 the templates. The dictionary is created by calling2409 self.get_NAME() for each of the attributes named in2410 COMPUTED_KEYS and recording those that do not return None.2411 The return value is always a new dictionary."""24122413if self._values is None:2414 values = {'': ''} # %()s expands to the empty string.24152416for key in self.COMPUTED_KEYS:2417 value =getattr(self,'get_%s'% (key,))()2418if value is not None:2419 values[key] = value24202421 self._values = values24222423return self._values.copy()24242425defget_refchange_recipients(self, refchange):2426"""Return the recipients for notifications about refchange.24272428 Return the list of email addresses to which notifications2429 about the specified ReferenceChange should be sent."""24302431raiseNotImplementedError()24322433defget_announce_recipients(self, annotated_tag_change):2434"""Return the recipients for notifications about annotated_tag_change.24352436 Return the list of email addresses to which notifications2437 about the specified AnnotatedTagChange should be sent."""24382439raiseNotImplementedError()24402441defget_reply_to_refchange(self, refchange):2442return self.get_pusher_email()24432444defget_revision_recipients(self, revision):2445"""Return the recipients for messages about revision.24462447 Return the list of email addresses to which notifications2448 about the specified Revision should be sent. This method2449 could be overridden, for example, to take into account the2450 contents of the revision when deciding whom to notify about2451 it. For example, there could be a scheme for users to express2452 interest in particular files or subdirectories, and only2453 receive notification emails for revisions that affecting those2454 files."""24552456raiseNotImplementedError()24572458defget_reply_to_commit(self, revision):2459return revision.author24602461defget_default_ref_ignore_regex(self):2462# The commit messages of git notes are essentially meaningless2463# and "filenames" in git notes commits are an implementational2464# detail that might surprise users at first. As such, we2465# would need a completely different method for handling emails2466# of git notes in order for them to be of benefit for users,2467# which we simply do not have right now.2468return"^refs/notes/"24692470deffilter_body(self, lines):2471"""Filter the lines intended for an email body.24722473 lines is an iterable over the lines that would go into the2474 email body. Filter it (e.g., limit the number of lines, the2475 line length, character set, etc.), returning another iterable.2476 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin2477 for classes implementing this functionality."""24782479return lines24802481deflog_msg(self, msg):2482"""Write the string msg on a log file or on stderr.24832484 Sends the text to stderr by default, override to change the behavior."""2485write_str(sys.stderr, msg)24862487deflog_warning(self, msg):2488"""Write the string msg on a log file or on stderr.24892490 Sends the text to stderr by default, override to change the behavior."""2491write_str(sys.stderr, msg)24922493deflog_error(self, msg):2494"""Write the string msg on a log file or on stderr.24952496 Sends the text to stderr by default, override to change the behavior."""2497write_str(sys.stderr, msg)249824992500classConfigEnvironmentMixin(Environment):2501"""A mixin that sets self.config to its constructor's config argument.25022503 This class's constructor consumes the "config" argument.25042505 Mixins that need to inspect the config should inherit from this2506 class (1) to make sure that "config" is still in the constructor2507 arguments with its own constructor runs and/or (2) to be sure that2508 self.config is set after construction."""25092510def__init__(self, config, **kw):2511super(ConfigEnvironmentMixin, self).__init__(**kw)2512 self.config = config251325142515classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):2516"""An Environment that reads most of its information from "git config"."""25172518@staticmethod2519defforbid_field_values(name, value, forbidden):2520for forbidden_val in forbidden:2521if value is not None and value.lower() == forbidden:2522raiseConfigurationException(2523'"%s" is not an allowed setting for%s'% (value, name)2524)25252526def__init__(self, config, **kw):2527super(ConfigOptionsEnvironmentMixin, self).__init__(2528 config=config, **kw2529)25302531for var, cfg in(2532('announce_show_shortlog','announceshortlog'),2533('refchange_showgraph','refchangeShowGraph'),2534('refchange_showlog','refchangeshowlog'),2535('quiet','quiet'),2536('stdout','stdout'),2537):2538 val = config.get_bool(cfg)2539if val is not None:2540setattr(self, var, val)25412542 commit_email_format = config.get('commitEmailFormat')2543if commit_email_format is not None:2544if commit_email_format !="html"and commit_email_format !="text":2545 self.log_warning(2546'*** Unknown value for multimailhook.commitEmailFormat:%s\n'%2547 commit_email_format +2548'*** Expected either "text" or "html". Ignoring.\n'2549)2550else:2551 self.commit_email_format = commit_email_format25522553 html_in_intro = config.get_bool('htmlInIntro')2554if html_in_intro is not None:2555 self.html_in_intro = html_in_intro25562557 html_in_footer = config.get_bool('htmlInFooter')2558if html_in_footer is not None:2559 self.html_in_footer = html_in_footer25602561 self.commitBrowseURL = config.get('commitBrowseURL')25622563 maxcommitemails = config.get('maxcommitemails')2564if maxcommitemails is not None:2565try:2566 self.maxcommitemails =int(maxcommitemails)2567exceptValueError:2568 self.log_warning(2569'*** Malformed value for multimailhook.maxCommitEmails:%s\n'2570% maxcommitemails +2571'*** Expected a number. Ignoring.\n'2572)25732574 diffopts = config.get('diffopts')2575if diffopts is not None:2576 self.diffopts = shlex.split(diffopts)25772578 graphopts = config.get('graphOpts')2579if graphopts is not None:2580 self.graphopts = shlex.split(graphopts)25812582 logopts = config.get('logopts')2583if logopts is not None:2584 self.logopts = shlex.split(logopts)25852586 commitlogopts = config.get('commitlogopts')2587if commitlogopts is not None:2588 self.commitlogopts = shlex.split(commitlogopts)25892590 date_substitute = config.get('dateSubstitute')2591if date_substitute =='none':2592 self.date_substitute =None2593elif date_substitute is not None:2594 self.date_substitute = date_substitute25952596 reply_to = config.get('replyTo')2597 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)2598 self.forbid_field_values('replyToRefchange',2599 self.__reply_to_refchange,2600['author'])2601 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)26022603 self.from_refchange = config.get('fromRefchange')2604 self.forbid_field_values('fromRefchange',2605 self.from_refchange,2606['author','none'])2607 self.from_commit = config.get('fromCommit')2608 self.forbid_field_values('fromCommit',2609 self.from_commit,2610['none'])26112612 combine = config.get_bool('combineWhenSingleCommit')2613if combine is not None:2614 self.combine_when_single_commit = combine26152616defget_administrator(self):2617return(2618 self.config.get('administrator')or2619 self.get_sender()or2620super(ConfigOptionsEnvironmentMixin, self).get_administrator()2621)26222623defget_repo_shortname(self):2624return(2625 self.config.get('reponame')or2626super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()2627)26282629defget_emailprefix(self):2630 emailprefix = self.config.get('emailprefix')2631if emailprefix is not None:2632 emailprefix = emailprefix.strip()2633if emailprefix:2634return emailprefix +' '2635else:2636return''2637else:2638return'[%s] '% (self.get_repo_shortname(),)26392640defget_sender(self):2641return self.config.get('envelopesender')26422643defprocess_addr(self, addr, change):2644if addr.lower() =='author':2645ifhasattr(change,'author'):2646return change.author2647else:2648return None2649elif addr.lower() =='pusher':2650return self.get_pusher_email()2651elif addr.lower() =='none':2652return None2653else:2654return addr26552656defget_fromaddr(self, change=None):2657 fromaddr = self.config.get('from')2658if change:2659 alt_fromaddr = change.get_alt_fromaddr()2660if alt_fromaddr:2661 fromaddr = alt_fromaddr2662if fromaddr:2663 fromaddr = self.process_addr(fromaddr, change)2664if fromaddr:2665return fromaddr2666returnsuper(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)26672668defget_reply_to_refchange(self, refchange):2669if self.__reply_to_refchange is None:2670returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)2671else:2672return self.process_addr(self.__reply_to_refchange, refchange)26732674defget_reply_to_commit(self, revision):2675if self.__reply_to_commit is None:2676returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)2677else:2678return self.process_addr(self.__reply_to_commit, revision)26792680defget_scancommitforcc(self):2681return self.config.get('scancommitforcc')268226832684classFilterLinesEnvironmentMixin(Environment):2685"""Handle encoding and maximum line length of body lines.26862687 emailmaxlinelength (int or None)26882689 The maximum length of any single line in the email body.2690 Longer lines are truncated at that length with ' [...]'2691 appended.26922693 strict_utf8 (bool)26942695 If this field is set to True, then the email body text is2696 expected to be UTF-8. Any invalid characters are2697 converted to U+FFFD, the Unicode replacement character2698 (encoded as UTF-8, of course).26992700 """27012702def__init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):2703super(FilterLinesEnvironmentMixin, self).__init__(**kw)2704 self.__strict_utf8= strict_utf82705 self.__emailmaxlinelength = emailmaxlinelength27062707deffilter_body(self, lines):2708 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)2709if self.__strict_utf8:2710if not PYTHON3:2711 lines = (line.decode(ENCODING,'replace')for line in lines)2712# Limit the line length in Unicode-space to avoid2713# splitting characters:2714if self.__emailmaxlinelength:2715 lines =limit_linelength(lines, self.__emailmaxlinelength)2716if not PYTHON3:2717 lines = (line.encode(ENCODING,'replace')for line in lines)2718elif self.__emailmaxlinelength:2719 lines =limit_linelength(lines, self.__emailmaxlinelength)27202721return lines272227232724classConfigFilterLinesEnvironmentMixin(2725 ConfigEnvironmentMixin,2726 FilterLinesEnvironmentMixin,2727):2728"""Handle encoding and maximum line length based on config."""27292730def__init__(self, config, **kw):2731 strict_utf8 = config.get_bool('emailstrictutf8', default=None)2732if strict_utf8 is not None:2733 kw['strict_utf8'] = strict_utf827342735 emailmaxlinelength = config.get('emailmaxlinelength')2736if emailmaxlinelength is not None:2737 kw['emailmaxlinelength'] =int(emailmaxlinelength)27382739super(ConfigFilterLinesEnvironmentMixin, self).__init__(2740 config=config, **kw2741)274227432744classMaxlinesEnvironmentMixin(Environment):2745"""Limit the email body to a specified number of lines."""27462747def__init__(self, emailmaxlines, **kw):2748super(MaxlinesEnvironmentMixin, self).__init__(**kw)2749 self.__emailmaxlines = emailmaxlines27502751deffilter_body(self, lines):2752 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)2753if self.__emailmaxlines:2754 lines =limit_lines(lines, self.__emailmaxlines)2755return lines275627572758classConfigMaxlinesEnvironmentMixin(2759 ConfigEnvironmentMixin,2760 MaxlinesEnvironmentMixin,2761):2762"""Limit the email body to the number of lines specified in config."""27632764def__init__(self, config, **kw):2765 emailmaxlines =int(config.get('emailmaxlines', default='0'))2766super(ConfigMaxlinesEnvironmentMixin, self).__init__(2767 config=config,2768 emailmaxlines=emailmaxlines,2769**kw2770)277127722773classFQDNEnvironmentMixin(Environment):2774"""A mixin that sets the host's FQDN to its constructor argument."""27752776def__init__(self, fqdn, **kw):2777super(FQDNEnvironmentMixin, self).__init__(**kw)2778 self.COMPUTED_KEYS += ['fqdn']2779 self.__fqdn = fqdn27802781defget_fqdn(self):2782"""Return the fully-qualified domain name for this host.27832784 Return None if it is unavailable or unwanted."""27852786return self.__fqdn278727882789classConfigFQDNEnvironmentMixin(2790 ConfigEnvironmentMixin,2791 FQDNEnvironmentMixin,2792):2793"""Read the FQDN from the config."""27942795def__init__(self, config, **kw):2796 fqdn = config.get('fqdn')2797super(ConfigFQDNEnvironmentMixin, self).__init__(2798 config=config,2799 fqdn=fqdn,2800**kw2801)280228032804classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):2805"""Get the FQDN by calling socket.getfqdn()."""28062807def__init__(self, **kw):2808super(ComputeFQDNEnvironmentMixin, self).__init__(2809 fqdn=socket.getfqdn(),2810**kw2811)281228132814classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):2815"""Deduce pusher_email from pusher by appending an emaildomain."""28162817def__init__(self, **kw):2818super(PusherDomainEnvironmentMixin, self).__init__(**kw)2819 self.__emaildomain = self.config.get('emaildomain')28202821defget_pusher_email(self):2822if self.__emaildomain:2823# Derive the pusher's full email address in the default way:2824return'%s@%s'% (self.get_pusher(), self.__emaildomain)2825else:2826returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()282728282829classStaticRecipientsEnvironmentMixin(Environment):2830"""Set recipients statically based on constructor parameters."""28312832def__init__(2833 self,2834 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,2835**kw2836):2837super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)28382839# The recipients for various types of notification emails, as2840# RFC 2822 email addresses separated by commas (or the empty2841# string if no recipients are configured). Although there is2842# a mechanism to choose the recipient lists based on on the2843# actual *contents* of the change being reported, we only2844# choose based on the *type* of the change. Therefore we can2845# compute them once and for all:2846if not(refchange_recipients or2847 announce_recipients or2848 revision_recipients or2849 scancommitforcc):2850raiseConfigurationException('No email recipients configured!')2851 self.__refchange_recipients = refchange_recipients2852 self.__announce_recipients = announce_recipients2853 self.__revision_recipients = revision_recipients28542855defget_refchange_recipients(self, refchange):2856return self.__refchange_recipients28572858defget_announce_recipients(self, annotated_tag_change):2859return self.__announce_recipients28602861defget_revision_recipients(self, revision):2862return self.__revision_recipients286328642865classConfigRecipientsEnvironmentMixin(2866 ConfigEnvironmentMixin,2867 StaticRecipientsEnvironmentMixin2868):2869"""Determine recipients statically based on config."""28702871def__init__(self, config, **kw):2872super(ConfigRecipientsEnvironmentMixin, self).__init__(2873 config=config,2874 refchange_recipients=self._get_recipients(2875 config,'refchangelist','mailinglist',2876),2877 announce_recipients=self._get_recipients(2878 config,'announcelist','refchangelist','mailinglist',2879),2880 revision_recipients=self._get_recipients(2881 config,'commitlist','mailinglist',2882),2883 scancommitforcc=config.get('scancommitforcc'),2884**kw2885)28862887def_get_recipients(self, config, *names):2888"""Return the recipients for a particular type of message.28892890 Return the list of email addresses to which a particular type2891 of notification email should be sent, by looking at the config2892 value for "multimailhook.$name" for each of names. Use the2893 value from the first name that is configured. The return2894 value is a (possibly empty) string containing RFC 2822 email2895 addresses separated by commas. If no configuration could be2896 found, raise a ConfigurationException."""28972898for name in names:2899 lines = config.get_all(name)2900if lines is not None:2901 lines = [line.strip()for line in lines]2902# Single "none" is a special value equivalen to empty string.2903if lines == ['none']:2904 lines = ['']2905return', '.join(lines)2906else:2907return''290829092910classStaticRefFilterEnvironmentMixin(Environment):2911"""Set branch filter statically based on constructor parameters."""29122913def__init__(self, ref_filter_incl_regex, ref_filter_excl_regex,2914 ref_filter_do_send_regex, ref_filter_dont_send_regex,2915**kw):2916super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)29172918if ref_filter_incl_regex and ref_filter_excl_regex:2919raiseConfigurationException(2920"Cannot specify both a ref inclusion and exclusion regex.")2921 self.__is_inclusion_filter =bool(ref_filter_incl_regex)2922 default_exclude = self.get_default_ref_ignore_regex()2923if ref_filter_incl_regex:2924 ref_filter_regex = ref_filter_incl_regex2925elif ref_filter_excl_regex:2926 ref_filter_regex = ref_filter_excl_regex +'|'+ default_exclude2927else:2928 ref_filter_regex = default_exclude2929try:2930 self.__compiled_regex = re.compile(ref_filter_regex)2931exceptException:2932raiseConfigurationException(2933'Invalid Ref Filter Regex "%s":%s'% (ref_filter_regex, sys.exc_info()[1]))29342935if ref_filter_do_send_regex and ref_filter_dont_send_regex:2936raiseConfigurationException(2937"Cannot specify both a ref doSend and dontSend regex.")2938if ref_filter_do_send_regex or ref_filter_dont_send_regex:2939 self.__is_do_send_filter =bool(ref_filter_do_send_regex)2940if ref_filter_incl_regex:2941 ref_filter_send_regex = ref_filter_incl_regex2942elif ref_filter_excl_regex:2943 ref_filter_send_regex = ref_filter_excl_regex2944else:2945 ref_filter_send_regex ='.*'2946 self.__is_do_send_filter =True2947try:2948 self.__send_compiled_regex = re.compile(ref_filter_send_regex)2949exceptException:2950raiseConfigurationException(2951'Invalid Ref Filter Regex "%s":%s'%2952(ref_filter_send_regex, sys.exc_info()[1]))2953else:2954 self.__send_compiled_regex = self.__compiled_regex2955 self.__is_do_send_filter = self.__is_inclusion_filter29562957defget_ref_filter_regex(self, send_filter=False):2958if send_filter:2959return self.__send_compiled_regex, self.__is_do_send_filter2960else:2961return self.__compiled_regex, self.__is_inclusion_filter296229632964classConfigRefFilterEnvironmentMixin(2965 ConfigEnvironmentMixin,2966 StaticRefFilterEnvironmentMixin2967):2968"""Determine branch filtering statically based on config."""29692970def_get_regex(self, config, key):2971"""Get a list of whitespace-separated regex. The refFilter* config2972 variables are multivalued (hence the use of get_all), and we2973 allow each entry to be a whitespace-separated list (hence the2974 split on each line). The whole thing is glued into a single regex."""2975 values = config.get_all(key)2976if values is None:2977return values2978 items = []2979for line in values:2980for i in line.split():2981 items.append(i)2982if items == []:2983return None2984return'|'.join(items)29852986def__init__(self, config, **kw):2987super(ConfigRefFilterEnvironmentMixin, self).__init__(2988 config=config,2989 ref_filter_incl_regex=self._get_regex(config,'refFilterInclusionRegex'),2990 ref_filter_excl_regex=self._get_regex(config,'refFilterExclusionRegex'),2991 ref_filter_do_send_regex=self._get_regex(config,'refFilterDoSendRegex'),2992 ref_filter_dont_send_regex=self._get_regex(config,'refFilterDontSendRegex'),2993**kw2994)299529962997classProjectdescEnvironmentMixin(Environment):2998"""Make a "projectdesc" value available for templates.29993000 By default, it is set to the first line of $GIT_DIR/description3001 (if that file is present and appears to be set meaningfully)."""30023003def__init__(self, **kw):3004super(ProjectdescEnvironmentMixin, self).__init__(**kw)3005 self.COMPUTED_KEYS += ['projectdesc']30063007defget_projectdesc(self):3008"""Return a one-line descripition of the project."""30093010 git_dir =get_git_dir()3011try:3012 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()3013if projectdesc and not projectdesc.startswith('Unnamed repository'):3014return projectdesc3015exceptIOError:3016pass30173018return'UNNAMED PROJECT'301930203021classGenericEnvironmentMixin(Environment):3022defget_pusher(self):3023return self.osenv.get('USER', self.osenv.get('USERNAME','unknown user'))302430253026classGenericEnvironment(3027 ProjectdescEnvironmentMixin,3028 ConfigMaxlinesEnvironmentMixin,3029 ComputeFQDNEnvironmentMixin,3030 ConfigFilterLinesEnvironmentMixin,3031 ConfigRecipientsEnvironmentMixin,3032 ConfigRefFilterEnvironmentMixin,3033 PusherDomainEnvironmentMixin,3034 ConfigOptionsEnvironmentMixin,3035 GenericEnvironmentMixin,3036 Environment,3037):3038pass303930403041classGitoliteEnvironmentMixin(Environment):3042defget_repo_shortname(self):3043# The gitolite environment variable $GL_REPO is a pretty good3044# repo_shortname (though it's probably not as good as a value3045# the user might have explicitly put in his config).3046return(3047 self.osenv.get('GL_REPO',None)or3048super(GitoliteEnvironmentMixin, self).get_repo_shortname()3049)30503051defget_pusher(self):3052return self.osenv.get('GL_USER','unknown user')30533054defget_fromaddr(self, change=None):3055 GL_USER = self.osenv.get('GL_USER')3056if GL_USER is not None:3057# Find the path to gitolite.conf. Note that gitolite v33058# did away with the GL_ADMINDIR and GL_CONF environment3059# variables (they are now hard-coded).3060 GL_ADMINDIR = self.osenv.get(3061'GL_ADMINDIR',3062 os.path.expanduser(os.path.join('~','.gitolite')))3063 GL_CONF = self.osenv.get(3064'GL_CONF',3065 os.path.join(GL_ADMINDIR,'conf','gitolite.conf'))3066if os.path.isfile(GL_CONF):3067 f =open(GL_CONF,'rU')3068try:3069 in_user_emails_section =False3070 re_template = r'^\s*#\s*%s\s*$'3071 re_begin, re_user, re_end = (3072 re.compile(re_template % x)3073for x in(3074 r'BEGIN\s+USER\s+EMAILS',3075 re.escape(GL_USER) + r'\s+(.*)',3076 r'END\s+USER\s+EMAILS',3077))3078for l in f:3079 l = l.rstrip('\n')3080if not in_user_emails_section:3081if re_begin.match(l):3082 in_user_emails_section =True3083continue3084if re_end.match(l):3085break3086 m = re_user.match(l)3087if m:3088return m.group(1)3089finally:3090 f.close()3091returnsuper(GitoliteEnvironmentMixin, self).get_fromaddr(change)309230933094classIncrementalDateTime(object):3095"""Simple wrapper to give incremental date/times.30963097 Each call will result in a date/time a second later than the3098 previous call. This can be used to falsify email headers, to3099 increase the likelihood that email clients sort the emails3100 correctly."""31013102def__init__(self):3103 self.time = time.time()3104 self.next = self.__next__# Python 2 backward compatibility31053106def__next__(self):3107 formatted =formatdate(self.time,True)3108 self.time +=13109return formatted311031113112classGitoliteEnvironment(3113 ProjectdescEnvironmentMixin,3114 ConfigMaxlinesEnvironmentMixin,3115 ComputeFQDNEnvironmentMixin,3116 ConfigFilterLinesEnvironmentMixin,3117 ConfigRecipientsEnvironmentMixin,3118 ConfigRefFilterEnvironmentMixin,3119 PusherDomainEnvironmentMixin,3120 ConfigOptionsEnvironmentMixin,3121 GitoliteEnvironmentMixin,3122 Environment,3123):3124pass312531263127classStashEnvironmentMixin(Environment):3128def__init__(self, user=None, repo=None, **kw):3129super(StashEnvironmentMixin, self).__init__(**kw)3130 self.__user = user3131 self.__repo = repo31323133defget_repo_shortname(self):3134return self.__repo31353136defget_pusher(self):3137return re.match('(.*?)\s*<', self.__user).group(1)31383139defget_pusher_email(self):3140return self.__user31413142defget_fromaddr(self, change=None):3143return self.__user314431453146classStashEnvironment(3147 StashEnvironmentMixin,3148 ProjectdescEnvironmentMixin,3149 ConfigMaxlinesEnvironmentMixin,3150 ComputeFQDNEnvironmentMixin,3151 ConfigFilterLinesEnvironmentMixin,3152 ConfigRecipientsEnvironmentMixin,3153 ConfigRefFilterEnvironmentMixin,3154 PusherDomainEnvironmentMixin,3155 ConfigOptionsEnvironmentMixin,3156 Environment,3157):3158pass315931603161classGerritEnvironmentMixin(Environment):3162def__init__(self, project=None, submitter=None, update_method=None, **kw):3163super(GerritEnvironmentMixin, self).__init__(**kw)3164 self.__project = project3165 self.__submitter = submitter3166 self.__update_method = update_method3167"Make an 'update_method' value available for templates."3168 self.COMPUTED_KEYS += ['update_method']31693170defget_repo_shortname(self):3171return self.__project31723173defget_pusher(self):3174if self.__submitter:3175if self.__submitter.find('<') != -1:3176# Submitter has a configured email, we transformed3177# __submitter into an RFC 2822 string already.3178return re.match('(.*?)\s*<', self.__submitter).group(1)3179else:3180# Submitter has no configured email, it's just his name.3181return self.__submitter3182else:3183# If we arrive here, this means someone pushed "Submit" from3184# the gerrit web UI for the CR (or used one of the programmatic3185# APIs to do the same, such as gerrit review) and the3186# merge/push was done by the Gerrit user. It was technically3187# triggered by someone else, but sadly we have no way of3188# determining who that someone else is at this point.3189return'Gerrit'# 'unknown user'?31903191defget_pusher_email(self):3192if self.__submitter:3193return self.__submitter3194else:3195returnsuper(GerritEnvironmentMixin, self).get_pusher_email()31963197defget_fromaddr(self, change=None):3198if self.__submitter and self.__submitter.find('<') != -1:3199return self.__submitter3200else:3201returnsuper(GerritEnvironmentMixin, self).get_fromaddr(change)32023203defget_default_ref_ignore_regex(self):3204 default =super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()3205return default +'|^refs/changes/|^refs/cache-automerge/|^refs/meta/'32063207defget_revision_recipients(self, revision):3208# Merge commits created by Gerrit when users hit "Submit this patchset"3209# in the Web UI (or do equivalently with REST APIs or the gerrit review3210# command) are not something users want to see an individual email for.3211# Filter them out.3212 committer =read_git_output(['log','--no-walk','--format=%cN',3213 revision.rev.sha1])3214if committer =='Gerrit Code Review':3215return[]3216else:3217returnsuper(GerritEnvironmentMixin, self).get_revision_recipients(revision)32183219defget_update_method(self):3220return self.__update_method322132223223classGerritEnvironment(3224 GerritEnvironmentMixin,3225 ProjectdescEnvironmentMixin,3226 ConfigMaxlinesEnvironmentMixin,3227 ComputeFQDNEnvironmentMixin,3228 ConfigFilterLinesEnvironmentMixin,3229 ConfigRecipientsEnvironmentMixin,3230 ConfigRefFilterEnvironmentMixin,3231 PusherDomainEnvironmentMixin,3232 ConfigOptionsEnvironmentMixin,3233 Environment,3234):3235pass323632373238classPush(object):3239"""Represent an entire push (i.e., a group of ReferenceChanges).32403241 It is easy to figure out what commits were added to a *branch* by3242 a Reference change:32433244 git rev-list change.old..change.new32453246 or removed from a *branch*:32473248 git rev-list change.new..change.old32493250 But it is not quite so trivial to determine which entirely new3251 commits were added to the *repository* by a push and which old3252 commits were discarded by a push. A big part of the job of this3253 class is to figure out these things, and to make sure that new3254 commits are only detailed once even if they were added to multiple3255 references.32563257 The first step is to determine the "other" references--those3258 unaffected by the current push. They are computed by listing all3259 references then removing any affected by this push. The results3260 are stored in Push._other_ref_sha1s.32613262 The commits contained in the repository before this push were32633264 git rev-list other1 other2 other3 ... change1.old change2.old ...32653266 Where "changeN.old" is the old value of one of the references3267 affected by this push.32683269 The commits contained in the repository after this push are32703271 git rev-list other1 other2 other3 ... change1.new change2.new ...32723273 The commits added by this push are the difference between these3274 two sets, which can be written32753276 git rev-list \3277 ^other1 ^other2 ... \3278 ^change1.old ^change2.old ... \3279 change1.new change2.new ...32803281 The commits removed by this push can be computed by32823283 git rev-list \3284 ^other1 ^other2 ... \3285 ^change1.new ^change2.new ... \3286 change1.old change2.old ...32873288 The last point is that it is possible that other pushes are3289 occurring simultaneously to this one, so reference values can3290 change at any time. It is impossible to eliminate all race3291 conditions, but we reduce the window of time during which problems3292 can occur by translating reference names to SHA1s as soon as3293 possible and working with SHA1s thereafter (because SHA1s are3294 immutable)."""32953296# A map {(changeclass, changetype): integer} specifying the order3297# that reference changes will be processed if multiple reference3298# changes are included in a single push. The order is significant3299# mostly because new commit notifications are threaded together3300# with the first reference change that includes the commit. The3301# following order thus causes commits to be grouped with branch3302# changes (as opposed to tag changes) if possible.3303 SORT_ORDER =dict(3304(value, i)for(i, value)inenumerate([3305(BranchChange,'update'),3306(BranchChange,'create'),3307(AnnotatedTagChange,'update'),3308(AnnotatedTagChange,'create'),3309(NonAnnotatedTagChange,'update'),3310(NonAnnotatedTagChange,'create'),3311(BranchChange,'delete'),3312(AnnotatedTagChange,'delete'),3313(NonAnnotatedTagChange,'delete'),3314(OtherReferenceChange,'update'),3315(OtherReferenceChange,'create'),3316(OtherReferenceChange,'delete'),3317])3318)33193320def__init__(self, environment, changes, ignore_other_refs=False):3321 self.changes =sorted(changes, key=self._sort_key)3322 self.__other_ref_sha1s =None3323 self.__cached_commits_spec = {}3324 self.environment = environment33253326if ignore_other_refs:3327 self.__other_ref_sha1s =set()33283329@classmethod3330def_sort_key(klass, change):3331return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)33323333@property3334def_other_ref_sha1s(self):3335"""The GitObjects referred to by references unaffected by this push.3336 """3337if self.__other_ref_sha1s is None:3338# The refnames being changed by this push:3339 updated_refs =set(3340 change.refname3341for change in self.changes3342)33433344# The SHA-1s of commits referred to by all references in this3345# repository *except* updated_refs:3346 sha1s =set()3347 fmt = (3348'%(objectname) %(objecttype) %(refname)\n'3349'%(*objectname) %(*objecttype)%(refname)'3350)3351 ref_filter_regex, is_inclusion_filter = \3352 self.environment.get_ref_filter_regex()3353for line inread_git_lines(3354['for-each-ref','--format=%s'% (fmt,)]):3355(sha1,type, name) = line.split(' ',2)3356if(sha1 andtype=='commit'and3357 name not in updated_refs and3358include_ref(name, ref_filter_regex, is_inclusion_filter)):3359 sha1s.add(sha1)33603361 self.__other_ref_sha1s = sha1s33623363return self.__other_ref_sha1s33643365def_get_commits_spec_incl(self, new_or_old, reference_change=None):3366"""Get new or old SHA-1 from one or each of the changed refs.33673368 Return a list of SHA-1 commit identifier strings suitable as3369 arguments to 'git rev-list' (or 'git log' or ...). The3370 returned identifiers are either the old or new values from one3371 or all of the changed references, depending on the values of3372 new_or_old and reference_change.33733374 new_or_old is either the string 'new' or the string 'old'. If3375 'new', the returned SHA-1 identifiers are the new values from3376 each changed reference. If 'old', the SHA-1 identifiers are3377 the old values from each changed reference.33783379 If reference_change is specified and not None, only the new or3380 old reference from the specified reference is included in the3381 return value.33823383 This function returns None if there are no matching revisions3384 (e.g., because a branch was deleted and new_or_old is 'new').3385 """33863387if not reference_change:3388 incl_spec =sorted(3389getattr(change, new_or_old).sha13390for change in self.changes3391ifgetattr(change, new_or_old)3392)3393if not incl_spec:3394 incl_spec =None3395elif notgetattr(reference_change, new_or_old).commit_sha1:3396 incl_spec =None3397else:3398 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]3399return incl_spec34003401def_get_commits_spec_excl(self, new_or_old):3402"""Get exclusion revisions for determining new or discarded commits.34033404 Return a list of strings suitable as arguments to 'git3405 rev-list' (or 'git log' or ...) that will exclude all3406 commits that, depending on the value of new_or_old, were3407 either previously in the repository (useful for determining3408 which commits are new to the repository) or currently in the3409 repository (useful for determining which commits were3410 discarded from the repository).34113412 new_or_old is either the string 'new' or the string 'old'. If3413 'new', the commits to be excluded are those that were in the3414 repository before the push. If 'old', the commits to be3415 excluded are those that are currently in the repository. """34163417 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]3418 excl_revs = self._other_ref_sha1s.union(3419getattr(change, old_or_new).sha13420for change in self.changes3421ifgetattr(change, old_or_new).typein['commit','tag']3422)3423return['^'+ sha1 for sha1 insorted(excl_revs)]34243425defget_commits_spec(self, new_or_old, reference_change=None):3426"""Get rev-list arguments for added or discarded commits.34273428 Return a list of strings suitable as arguments to 'git3429 rev-list' (or 'git log' or ...) that select those commits3430 that, depending on the value of new_or_old, are either new to3431 the repository or were discarded from the repository.34323433 new_or_old is either the string 'new' or the string 'old'. If3434 'new', the returned list is used to select commits that are3435 new to the repository. If 'old', the returned value is used3436 to select the commits that have been discarded from the3437 repository.34383439 If reference_change is specified and not None, the new or3440 discarded commits are limited to those that are reachable from3441 the new or old value of the specified reference.34423443 This function returns None if there are no added (or discarded)3444 revisions.3445 """3446 key = (new_or_old, reference_change)3447if key not in self.__cached_commits_spec:3448 ret = self._get_commits_spec_incl(new_or_old, reference_change)3449if ret is not None:3450 ret.extend(self._get_commits_spec_excl(new_or_old))3451 self.__cached_commits_spec[key] = ret3452return self.__cached_commits_spec[key]34533454defget_new_commits(self, reference_change=None):3455"""Return a list of commits added by this push.34563457 Return a list of the object names of commits that were added3458 by the part of this push represented by reference_change. If3459 reference_change is None, then return a list of *all* commits3460 added by this push."""34613462 spec = self.get_commits_spec('new', reference_change)3463returngit_rev_list(spec)34643465defget_discarded_commits(self, reference_change):3466"""Return a list of commits discarded by this push.34673468 Return a list of the object names of commits that were3469 entirely discarded from the repository by the part of this3470 push represented by reference_change."""34713472 spec = self.get_commits_spec('old', reference_change)3473returngit_rev_list(spec)34743475defsend_emails(self, mailer, body_filter=None):3476"""Use send all of the notification emails needed for this push.34773478 Use send all of the notification emails (including reference3479 change emails and commit emails) needed for this push. Send3480 the emails using mailer. If body_filter is not None, then use3481 it to filter the lines that are intended for the email3482 body."""34833484# The sha1s of commits that were introduced by this push.3485# They will be removed from this set as they are processed, to3486# guarantee that one (and only one) email is generated for3487# each new commit.3488 unhandled_sha1s =set(self.get_new_commits())3489 send_date =IncrementalDateTime()3490for change in self.changes:3491 sha1s = []3492for sha1 inreversed(list(self.get_new_commits(change))):3493if sha1 in unhandled_sha1s:3494 sha1s.append(sha1)3495 unhandled_sha1s.remove(sha1)34963497# Check if we've got anyone to send to3498if not change.recipients:3499 change.environment.log_warning(3500'*** no recipients configured so no email will be sent\n'3501'*** for%rupdate%s->%s\n'3502% (change.refname, change.old.sha1, change.new.sha1,)3503)3504else:3505if not change.environment.quiet:3506 change.environment.log_msg(3507'Sending notification emails to:%s\n'% (change.recipients,))3508 extra_values = {'send_date': next(send_date)}35093510 rev = change.send_single_combined_email(sha1s)3511if rev:3512 mailer.send(3513 change.generate_combined_email(self, rev, body_filter, extra_values),3514 rev.recipients,3515)3516# This change is now fully handled; no need to handle3517# individual revisions any further.3518continue3519else:3520 mailer.send(3521 change.generate_email(self, body_filter, extra_values),3522 change.recipients,3523)35243525 max_emails = change.environment.maxcommitemails3526if max_emails andlen(sha1s) > max_emails:3527 change.environment.log_warning(3528'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s) +3529'*** Try setting multimailhook.maxCommitEmails to a greater value\n'+3530'*** Currently, multimailhook.maxCommitEmails=%d\n'% max_emails3531)3532return35333534for(num, sha1)inenumerate(sha1s):3535 rev =Revision(change,GitObject(sha1), num=num +1, tot=len(sha1s))3536if not rev.recipients and rev.cc_recipients:3537 change.environment.log_msg('*** Replacing Cc: with To:\n')3538 rev.recipients = rev.cc_recipients3539 rev.cc_recipients =None3540if rev.recipients:3541 extra_values = {'send_date': next(send_date)}3542 mailer.send(3543 rev.generate_email(self, body_filter, extra_values),3544 rev.recipients,3545)35463547# Consistency check:3548if unhandled_sha1s:3549 change.environment.log_error(3550'ERROR: No emails were sent for the following new commits:\n'3551'%s\n'3552% ('\n'.join(sorted(unhandled_sha1s)),)3553)355435553556definclude_ref(refname, ref_filter_regex, is_inclusion_filter):3557 does_match =bool(ref_filter_regex.search(refname))3558if is_inclusion_filter:3559return does_match3560else:# exclusion filter -- we include the ref if the regex doesn't match3561return not does_match356235633564defrun_as_post_receive_hook(environment, mailer):3565 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)3566 changes = []3567for line in sys.stdin:3568(oldrev, newrev, refname) = line.strip().split(' ',2)3569if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3570continue3571 changes.append(3572 ReferenceChange.create(environment, oldrev, newrev, refname)3573)3574if changes:3575 push =Push(environment, changes)3576 push.send_emails(mailer, body_filter=environment.filter_body)3577ifhasattr(mailer,'__del__'):3578 mailer.__del__()357935803581defrun_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):3582 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)3583if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3584return3585 changes = [3586 ReferenceChange.create(3587 environment,3588read_git_output(['rev-parse','--verify', oldrev]),3589read_git_output(['rev-parse','--verify', newrev]),3590 refname,3591),3592]3593 push =Push(environment, changes, force_send)3594 push.send_emails(mailer, body_filter=environment.filter_body)3595ifhasattr(mailer,'__del__'):3596 mailer.__del__()359735983599defchoose_mailer(config, environment):3600 mailer = config.get('mailer', default='sendmail')36013602if mailer =='smtp':3603 smtpserver = config.get('smtpserver', default='localhost')3604 smtpservertimeout =float(config.get('smtpservertimeout', default=10.0))3605 smtpserverdebuglevel =int(config.get('smtpserverdebuglevel', default=0))3606 smtpencryption = config.get('smtpencryption', default='none')3607 smtpuser = config.get('smtpuser', default='')3608 smtppass = config.get('smtppass', default='')3609 smtpcacerts = config.get('smtpcacerts', default='')3610 mailer =SMTPMailer(3611 envelopesender=(environment.get_sender()or environment.get_fromaddr()),3612 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,3613 smtpserverdebuglevel=smtpserverdebuglevel,3614 smtpencryption=smtpencryption,3615 smtpuser=smtpuser,3616 smtppass=smtppass,3617 smtpcacerts=smtpcacerts3618)3619elif mailer =='sendmail':3620 command = config.get('sendmailcommand')3621if command:3622 command = shlex.split(command)3623 mailer =SendMailer(command=command, envelopesender=environment.get_sender())3624else:3625 environment.log_error(3626'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer +3627'please use one of "smtp" or "sendmail".\n'3628)3629 sys.exit(1)3630return mailer363136323633KNOWN_ENVIRONMENTS = {3634'generic': GenericEnvironmentMixin,3635'gitolite': GitoliteEnvironmentMixin,3636'stash': StashEnvironmentMixin,3637'gerrit': GerritEnvironmentMixin,3638}363936403641defchoose_environment(config, osenv=None, env=None, recipients=None,3642 hook_info=None):3643if not osenv:3644 osenv = os.environ36453646 environment_mixins = [3647 ConfigRefFilterEnvironmentMixin,3648 ProjectdescEnvironmentMixin,3649 ConfigMaxlinesEnvironmentMixin,3650 ComputeFQDNEnvironmentMixin,3651 ConfigFilterLinesEnvironmentMixin,3652 PusherDomainEnvironmentMixin,3653 ConfigOptionsEnvironmentMixin,3654]3655 environment_kw = {3656'osenv': osenv,3657'config': config,3658}36593660if not env:3661 env = config.get('environment')36623663if not env:3664if'GL_USER'in osenv and'GL_REPO'in osenv:3665 env ='gitolite'3666else:3667 env ='generic'36683669 environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])36703671if env =='stash':3672 environment_kw['user'] = hook_info['stash_user']3673 environment_kw['repo'] = hook_info['stash_repo']3674elif env =='gerrit':3675 environment_kw['project'] = hook_info['project']3676 environment_kw['submitter'] = hook_info['submitter']3677 environment_kw['update_method'] = hook_info['update_method']36783679if recipients:3680 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)3681 environment_kw['refchange_recipients'] = recipients3682 environment_kw['announce_recipients'] = recipients3683 environment_kw['revision_recipients'] = recipients3684 environment_kw['scancommitforcc'] = config.get('scancommitforcc')3685else:3686 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)36873688 environment_klass =type(3689'EffectiveEnvironment',3690tuple(environment_mixins) + (Environment,),3691{},3692)3693returnenvironment_klass(**environment_kw)369436953696defget_version():3697 oldcwd = os.getcwd()3698try:3699try:3700 os.chdir(os.path.dirname(os.path.realpath(__file__)))3701 git_version =read_git_output(['describe','--tags','HEAD'])3702if git_version == __version__:3703return git_version3704else:3705return'%s(%s)'% (__version__, git_version)3706except:3707pass3708finally:3709 os.chdir(oldcwd)3710return __version__371137123713defcompute_gerrit_options(options, args, required_gerrit_options):3714if None in required_gerrit_options:3715raiseSystemExit("Error: Specify all of --oldrev, --newrev, --refname, "3716"and --project; or none of them.")37173718if options.environment not in(None,'gerrit'):3719raiseSystemExit("Non-gerrit environments incompatible with --oldrev, "3720"--newrev, --refname, and --project")3721 options.environment ='gerrit'37223723if args:3724raiseSystemExit("Error: Positional parameters not allowed with "3725"--oldrev, --newrev, and --refname.")37263727# Gerrit oddly omits 'refs/heads/' in the refname when calling3728# ref-updated hook; put it back.3729 git_dir =get_git_dir()3730if(not os.path.exists(os.path.join(git_dir, options.refname))and3731 os.path.exists(os.path.join(git_dir,'refs','heads',3732 options.refname))):3733 options.refname ='refs/heads/'+ options.refname37343735# Convert each string option unicode for Python3.3736if PYTHON3:3737 opts = ['environment','recipients','oldrev','newrev','refname',3738'project','submitter','stash-user','stash-repo']3739for opt in opts:3740if nothasattr(options, opt):3741continue3742 obj =getattr(options, opt)3743if obj:3744 enc = obj.encode('utf-8','surrogateescape')3745 dec = enc.decode('utf-8','replace')3746setattr(options, opt, dec)37473748# New revisions can appear in a gerrit repository either due to someone3749# pushing directly (in which case options.submitter will be set), or they3750# can press "Submit this patchset" in the web UI for some CR (in which3751# case options.submitter will not be set and gerrit will not have provided3752# us the information about who pressed the button).3753#3754# Note for the nit-picky: I'm lumping in REST API calls and the ssh3755# gerrit review command in with "Submit this patchset" button, since they3756# have the same effect.3757if options.submitter:3758 update_method ='pushed'3759# The submitter argument is almost an RFC 2822 email address; change it3760# from 'User Name (email@domain)' to 'User Name <email@domain>' so it is3761 options.submitter = options.submitter.replace('(','<').replace(')','>')3762else:3763 update_method ='submitted'3764# Gerrit knew who submitted this patchset, but threw that information3765# away when it invoked this hook. However, *IF* Gerrit created a3766# merge to bring the patchset in (project 'Submit Type' is either3767# "Always Merge", or is "Merge if Necessary" and happens to be3768# necessary for this particular CR), then it will have the committer3769# of that merge be 'Gerrit Code Review' and the author will be the3770# person who requested the submission of the CR. Since this is fairly3771# likely for most gerrit installations (of a reasonable size), it's3772# worth the extra effort to try to determine the actual submitter.3773 rev_info =read_git_lines(['log','--no-walk','--merges',3774'--format=%cN%n%aN <%aE>', options.newrev])3775if rev_info and rev_info[0] =='Gerrit Code Review':3776 options.submitter = rev_info[1]37773778# We pass back refname, oldrev, newrev as args because then the3779# gerrit ref-updated hook is much like the git update hook3780return(options,3781[options.refname, options.oldrev, options.newrev],3782{'project': options.project,'submitter': options.submitter,3783'update_method': update_method})378437853786defcheck_hook_specific_args(options, args):3787# First check for stash arguments3788if(options.stash_user is None) != (options.stash_repo is None):3789raiseSystemExit("Error: Specify both of --stash-user and "3790"--stash-repo or neither.")3791if options.stash_user:3792 options.environment ='stash'3793return options, args, {'stash_user': options.stash_user,3794'stash_repo': options.stash_repo}37953796# Finally, check for gerrit specific arguments3797 required_gerrit_options = (options.oldrev, options.newrev, options.refname,3798 options.project)3799if required_gerrit_options != (None,) *4:3800returncompute_gerrit_options(options, args, required_gerrit_options)38013802# No special options in use, just return what we started with3803return options, args, {}380438053806defmain(args):3807 parser = optparse.OptionParser(3808 description=__doc__,3809 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',3810)38113812 parser.add_option(3813'--environment','--env', action='store',type='choice',3814 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,3815help=(3816'Choose type of environment is in use. Default is taken from '3817'multimailhook.environment if set; otherwise "generic".'3818),3819)3820 parser.add_option(3821'--stdout', action='store_true', default=False,3822help='Output emails to stdout rather than sending them.',3823)3824 parser.add_option(3825'--recipients', action='store', default=None,3826help='Set list of email recipients for all types of emails.',3827)3828 parser.add_option(3829'--show-env', action='store_true', default=False,3830help=(3831'Write to stderr the values determined for the environment '3832'(intended for debugging purposes).'3833),3834)3835 parser.add_option(3836'--force-send', action='store_true', default=False,3837help=(3838'Force sending refchange email when using as an update hook. '3839'This is useful to work around the unreliable new commits '3840'detection in this mode.'3841),3842)3843 parser.add_option(3844'-c', metavar="<name>=<value>", action='append',3845help=(3846'Pass a configuration parameter through to git. The value given '3847'will override values from configuration files. See the -c option '3848'of git(1) for more details. (Only works with git >= 1.7.3)'3849),3850)3851 parser.add_option(3852'--version','-v', action='store_true', default=False,3853help=(3854"Display git-multimail's version"3855),3856)3857# The following options permit this script to be run as a gerrit3858# ref-updated hook. See e.g.3859# code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt3860# We suppress help for these items, since these are specific to gerrit,3861# and we don't want users directly using them any way other than how the3862# gerrit ref-updated hook is called.3863 parser.add_option('--oldrev', action='store',help=optparse.SUPPRESS_HELP)3864 parser.add_option('--newrev', action='store',help=optparse.SUPPRESS_HELP)3865 parser.add_option('--refname', action='store',help=optparse.SUPPRESS_HELP)3866 parser.add_option('--project', action='store',help=optparse.SUPPRESS_HELP)3867 parser.add_option('--submitter', action='store',help=optparse.SUPPRESS_HELP)38683869# The following allow this to be run as a stash asynchronous post-receive3870# hook (almost identical to a git post-receive hook but triggered also for3871# merges of pull requests from the UI). We suppress help for these items,3872# since these are specific to stash.3873 parser.add_option('--stash-user', action='store',help=optparse.SUPPRESS_HELP)3874 parser.add_option('--stash-repo', action='store',help=optparse.SUPPRESS_HELP)38753876(options, args) = parser.parse_args(args)3877(options, args, hook_info) =check_hook_specific_args(options, args)38783879if options.version:3880 sys.stdout.write('git-multimail version '+get_version() +'\n')3881return38823883if options.c:3884 Config.add_config_parameters(options.c)38853886 config =Config('multimailhook')38873888try:3889 environment =choose_environment(3890 config, osenv=os.environ,3891 env=options.environment,3892 recipients=options.recipients,3893 hook_info=hook_info,3894)38953896if options.show_env:3897 sys.stderr.write('Environment values:\n')3898for(k, v)insorted(environment.get_values().items()):3899 sys.stderr.write('%s:%r\n'% (k, v))3900 sys.stderr.write('\n')39013902if options.stdout or environment.stdout:3903 mailer =OutputMailer(sys.stdout)3904else:3905 mailer =choose_mailer(config, environment)39063907# Dual mode: if arguments were specified on the command line, run3908# like an update hook; otherwise, run as a post-receive hook.3909if args:3910iflen(args) !=3:3911 parser.error('Need zero or three non-option arguments')3912(refname, oldrev, newrev) = args3913run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)3914else:3915run_as_post_receive_hook(environment, mailer)3916except ConfigurationException:3917 sys.exit(sys.exc_info()[1])3918exceptException:3919 t, e, tb = sys.exc_info()3920import traceback3921 sys.stdout.write('\n')3922 sys.stdout.write('Exception\''+ t.__name__+3923'\'raised. Please report this as a bug to\n')3924 sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')3925 sys.stdout.write('with the information below:\n\n')3926 sys.stdout.write('git-multimail version '+get_version() +'\n')3927 sys.stdout.write('Python version '+ sys.version +'\n')3928 traceback.print_exc(file=sys.stdout)3929 sys.exit(1)39303931if __name__ =='__main__':3932main(sys.argv[1:])