1#! /usr/bin/env python 2 3__version__ ='1.2.0' 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 60import time 61import cgi 62 63PYTHON3 = sys.version_info >= (3,0) 64 65if sys.version_info <= (2,5): 66defall(iterable): 67for element in iterable: 68if not element: 69return False 70return True 71 72 73defis_ascii(s): 74returnall(ord(c) <128andord(c) >0for c in s) 75 76 77if PYTHON3: 78defstr_to_bytes(s): 79return s.encode(ENCODING) 80 81defbytes_to_str(s): 82return s.decode(ENCODING) 83 84unicode=str 85 86defwrite_str(f, msg): 87# Try outputing with the default encoding. If it fails, 88# try UTF-8. 89try: 90 f.buffer.write(msg.encode(sys.getdefaultencoding())) 91exceptUnicodeEncodeError: 92 f.buffer.write(msg.encode(ENCODING)) 93else: 94defstr_to_bytes(s): 95return s 96 97defbytes_to_str(s): 98return s 99 100defwrite_str(f, msg): 101 f.write(msg) 102 103defnext(it): 104return it.next() 105 106 107try: 108from email.charset import Charset 109from email.utils import make_msgid 110from email.utils import getaddresses 111from email.utils import formataddr 112from email.utils import formatdate 113from email.header import Header 114exceptImportError: 115# Prior to Python 2.5, the email module used different names: 116from email.Charset import Charset 117from email.Utils import make_msgid 118from email.Utils import getaddresses 119from email.Utils import formataddr 120from email.Utils import formatdate 121from email.Header import Header 122 123 124DEBUG =False 125 126ZEROS ='0'*40 127LOGBEGIN ='- Log -----------------------------------------------------------------\n' 128LOGEND ='-----------------------------------------------------------------------\n' 129 130ADDR_HEADERS =set(['from','to','cc','bcc','reply-to','sender']) 131 132# It is assumed in many places that the encoding is uniformly UTF-8, 133# so changing these constants is unsupported. But define them here 134# anyway, to make it easier to find (at least most of) the places 135# where the encoding is important. 136(ENCODING, CHARSET) = ('UTF-8','utf-8') 137 138 139REF_CREATED_SUBJECT_TEMPLATE = ( 140'%(emailprefix)s%(refname_type)s %(short_refname)screated' 141' (now%(newrev_short)s)' 142) 143REF_UPDATED_SUBJECT_TEMPLATE = ( 144'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 145' (%(oldrev_short)s->%(newrev_short)s)' 146) 147REF_DELETED_SUBJECT_TEMPLATE = ( 148'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 149' (was%(oldrev_short)s)' 150) 151 152COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( 153'%(emailprefix)s%(refname_type)s %(short_refname)supdated:%(oneline)s' 154) 155 156REFCHANGE_HEADER_TEMPLATE ="""\ 157Date:%(send_date)s 158To:%(recipients)s 159Subject:%(subject)s 160MIME-Version: 1.0 161Content-Type: text/%(contenttype)s; charset=%(charset)s 162Content-Transfer-Encoding: 8bit 163Message-ID:%(msgid)s 164From:%(fromaddr)s 165Reply-To:%(reply_to)s 166X-Git-Host:%(fqdn)s 167X-Git-Repo:%(repo_shortname)s 168X-Git-Refname:%(refname)s 169X-Git-Reftype:%(refname_type)s 170X-Git-Oldrev:%(oldrev)s 171X-Git-Newrev:%(newrev)s 172X-Git-NotificationType: ref_changed 173X-Git-Multimail-Version:%(multimail_version)s 174Auto-Submitted: auto-generated 175""" 176 177REFCHANGE_INTRO_TEMPLATE ="""\ 178This is an automated email from the git hooks/post-receive script. 179 180%(pusher)spushed a change to%(refname_type)s %(short_refname)s 181in repository%(repo_shortname)s. 182 183""" 184 185 186FOOTER_TEMPLATE ="""\ 187 188--\n\ 189To stop receiving notification emails like this one, please contact 190%(administrator)s. 191""" 192 193 194REWIND_ONLY_TEMPLATE ="""\ 195This update removed existing revisions from the reference, leaving the 196reference pointing at a previous point in the repository history. 197 198 * -- * -- N%(refname)s(%(newrev_short)s) 199\\ 200 O -- O -- O (%(oldrev_short)s) 201 202Any revisions marked "omits" are not gone; other references still 203refer to them. Any revisions marked "discards" are gone forever. 204""" 205 206 207NON_FF_TEMPLATE ="""\ 208This update added new revisions after undoing existing revisions. 209That is to say, some revisions that were in the old version of the 210%(refname_type)sare not in the new version. This situation occurs 211when a user --force pushes a change and generates a repository 212containing something like this: 213 214 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 215\\ 216 N -- N -- N%(refname)s(%(newrev_short)s) 217 218You should already have received notification emails for all of the O 219revisions, and so the following emails describe only the N revisions 220from the common base, B. 221 222Any revisions marked "omits" are not gone; other references still 223refer to them. Any revisions marked "discards" are gone forever. 224""" 225 226 227NO_NEW_REVISIONS_TEMPLATE ="""\ 228No new revisions were added by this update. 229""" 230 231 232DISCARDED_REVISIONS_TEMPLATE ="""\ 233This change permanently discards the following revisions: 234""" 235 236 237NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 238The revisions that were on this%(refname_type)sare still contained in 239other references; therefore, this change does not discard any commits 240from the repository. 241""" 242 243 244NEW_REVISIONS_TEMPLATE ="""\ 245The%(tot)srevisions listed above as "new" are entirely new to this 246repository and will be described in separate emails. The revisions 247listed as "adds" were already present in the repository and have only 248been added to this reference. 249 250""" 251 252 253TAG_CREATED_TEMPLATE ="""\ 254 at%(newrev_short)-9s (%(newrev_type)s) 255""" 256 257 258TAG_UPDATED_TEMPLATE ="""\ 259*** WARNING: tag%(short_refname)swas modified! *** 260 261 from%(oldrev_short)-9s (%(oldrev_type)s) 262 to%(newrev_short)-9s (%(newrev_type)s) 263""" 264 265 266TAG_DELETED_TEMPLATE ="""\ 267*** WARNING: tag%(short_refname)swas deleted! *** 268 269""" 270 271 272# The template used in summary tables. It looks best if this uses the 273# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 274BRIEF_SUMMARY_TEMPLATE ="""\ 275%(action)10s%(rev_short)-9s%(text)s 276""" 277 278 279NON_COMMIT_UPDATE_TEMPLATE ="""\ 280This is an unusual reference change because the reference did not 281refer to a commit either before or after the change. We do not know 282how to provide full information about this reference change. 283""" 284 285 286REVISION_HEADER_TEMPLATE ="""\ 287Date:%(send_date)s 288To:%(recipients)s 289Cc:%(cc_recipients)s 290Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 291MIME-Version: 1.0 292Content-Type: text/%(contenttype)s; charset=%(charset)s 293Content-Transfer-Encoding: 8bit 294From:%(fromaddr)s 295Reply-To:%(reply_to)s 296In-Reply-To:%(reply_to_msgid)s 297References:%(reply_to_msgid)s 298X-Git-Host:%(fqdn)s 299X-Git-Repo:%(repo_shortname)s 300X-Git-Refname:%(refname)s 301X-Git-Reftype:%(refname_type)s 302X-Git-Rev:%(rev)s 303X-Git-NotificationType: diff 304X-Git-Multimail-Version:%(multimail_version)s 305Auto-Submitted: auto-generated 306""" 307 308REVISION_INTRO_TEMPLATE ="""\ 309This is an automated email from the git hooks/post-receive script. 310 311%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 312in repository%(repo_shortname)s. 313 314""" 315 316 317REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 318 319 320# Combined, meaning refchange+revision email (for single-commit additions) 321COMBINED_HEADER_TEMPLATE ="""\ 322Date:%(send_date)s 323To:%(recipients)s 324Subject:%(subject)s 325MIME-Version: 1.0 326Content-Type: text/%(contenttype)s; charset=%(charset)s 327Content-Transfer-Encoding: 8bit 328Message-ID:%(msgid)s 329From:%(fromaddr)s 330Reply-To:%(reply_to)s 331X-Git-Host:%(fqdn)s 332X-Git-Repo:%(repo_shortname)s 333X-Git-Refname:%(refname)s 334X-Git-Reftype:%(refname_type)s 335X-Git-Oldrev:%(oldrev)s 336X-Git-Newrev:%(newrev)s 337X-Git-Rev:%(rev)s 338X-Git-NotificationType: ref_changed_plus_diff 339X-Git-Multimail-Version:%(multimail_version)s 340Auto-Submitted: auto-generated 341""" 342 343COMBINED_INTRO_TEMPLATE ="""\ 344This is an automated email from the git hooks/post-receive script. 345 346%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 347in repository%(repo_shortname)s. 348 349""" 350 351COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE 352 353 354classCommandError(Exception): 355def__init__(self, cmd, retcode): 356 self.cmd = cmd 357 self.retcode = retcode 358Exception.__init__( 359 self, 360'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 361) 362 363 364classConfigurationException(Exception): 365pass 366 367 368# The "git" program (this could be changed to include a full path): 369GIT_EXECUTABLE ='git' 370 371 372# How "git" should be invoked (including global arguments), as a list 373# of words. This variable is usually initialized automatically by 374# read_git_output() via choose_git_command(), but if a value is set 375# here then it will be used unconditionally. 376GIT_CMD =None 377 378 379defchoose_git_command(): 380"""Decide how to invoke git, and record the choice in GIT_CMD.""" 381 382global GIT_CMD 383 384if GIT_CMD is None: 385try: 386# Check to see whether the "-c" option is accepted (it was 387# only added in Git 1.7.2). We don't actually use the 388# output of "git --version", though if we needed more 389# specific version information this would be the place to 390# do it. 391 cmd = [GIT_EXECUTABLE,'-c','foo.bar=baz','--version'] 392read_output(cmd) 393 GIT_CMD = [GIT_EXECUTABLE,'-c','i18n.logoutputencoding=%s'% (ENCODING,)] 394except CommandError: 395 GIT_CMD = [GIT_EXECUTABLE] 396 397 398defread_git_output(args,input=None, keepends=False, **kw): 399"""Read the output of a Git command.""" 400 401if GIT_CMD is None: 402choose_git_command() 403 404returnread_output(GIT_CMD + args,input=input, keepends=keepends, **kw) 405 406 407defread_output(cmd,input=None, keepends=False, **kw): 408ifinput: 409 stdin = subprocess.PIPE 410input=str_to_bytes(input) 411else: 412 stdin =None 413 p = subprocess.Popen( 414 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 415) 416(out, err) = p.communicate(input) 417 out =bytes_to_str(out) 418 retcode = p.wait() 419if retcode: 420raiseCommandError(cmd, retcode) 421if not keepends: 422 out = out.rstrip('\n\r') 423return out 424 425 426defread_git_lines(args, keepends=False, **kw): 427"""Return the lines output by Git command. 428 429 Return as single lines, with newlines stripped off.""" 430 431returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 432 433 434defgit_rev_list_ish(cmd, spec, args=None, **kw): 435"""Common functionality for invoking a 'git rev-list'-like command. 436 437 Parameters: 438 * cmd is the Git command to run, e.g., 'rev-list' or 'log'. 439 * spec is a list of revision arguments to pass to the named 440 command. If None, this function returns an empty list. 441 * args is a list of extra arguments passed to the named command. 442 * All other keyword arguments (if any) are passed to the 443 underlying read_git_lines() function. 444 445 Return the output of the Git command in the form of a list, one 446 entry per output line. 447 """ 448if spec is None: 449return[] 450if args is None: 451 args = [] 452 args = [cmd,'--stdin'] + args 453 spec_stdin =''.join(s +'\n'for s in spec) 454returnread_git_lines(args,input=spec_stdin, **kw) 455 456 457defgit_rev_list(spec, **kw): 458"""Run 'git rev-list' with the given list of revision arguments. 459 460 See git_rev_list_ish() for parameter and return value 461 documentation. 462 """ 463returngit_rev_list_ish('rev-list', spec, **kw) 464 465 466defgit_log(spec, **kw): 467"""Run 'git log' with the given list of revision arguments. 468 469 See git_rev_list_ish() for parameter and return value 470 documentation. 471 """ 472returngit_rev_list_ish('log', spec, **kw) 473 474 475defheader_encode(text, header_name=None): 476"""Encode and line-wrap the value of an email header field.""" 477 478# Convert to unicode, if required. 479if notisinstance(text,unicode): 480 text =unicode(text,'utf-8') 481 482ifis_ascii(text): 483 charset ='ascii' 484else: 485 charset ='utf-8' 486 487returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 488 489 490defaddr_header_encode(text, header_name=None): 491"""Encode and line-wrap the value of an email header field containing 492 email addresses.""" 493 494# Convert to unicode, if required. 495if notisinstance(text,unicode): 496 text =unicode(text,'utf-8') 497 498 text =', '.join( 499formataddr((header_encode(name), emailaddr)) 500for name, emailaddr ingetaddresses([text]) 501) 502 503ifis_ascii(text): 504 charset ='ascii' 505else: 506 charset ='utf-8' 507 508returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 509 510 511classConfig(object): 512def__init__(self, section, git_config=None): 513"""Represent a section of the git configuration. 514 515 If git_config is specified, it is passed to "git config" in 516 the GIT_CONFIG environment variable, meaning that "git config" 517 will read the specified path rather than the Git default 518 config paths.""" 519 520 self.section = section 521if git_config: 522 self.env = os.environ.copy() 523 self.env['GIT_CONFIG'] = git_config 524else: 525 self.env =None 526 527@staticmethod 528def_split(s): 529"""Split NUL-terminated values.""" 530 531 words = s.split('\0') 532assert words[-1] =='' 533return words[:-1] 534 535defget(self, name, default=None): 536try: 537 values = self._split(read_git_output( 538['config','--get','--null','%s.%s'% (self.section, name)], 539 env=self.env, keepends=True, 540)) 541assertlen(values) ==1 542return values[0] 543except CommandError: 544return default 545 546defget_bool(self, name, default=None): 547try: 548 value =read_git_output( 549['config','--get','--bool','%s.%s'% (self.section, name)], 550 env=self.env, 551) 552except CommandError: 553return default 554return value =='true' 555 556defget_all(self, name, default=None): 557"""Read a (possibly multivalued) setting from the configuration. 558 559 Return the result as a list of values, or default if the name 560 is unset.""" 561 562try: 563return self._split(read_git_output( 564['config','--get-all','--null','%s.%s'% (self.section, name)], 565 env=self.env, keepends=True, 566)) 567except CommandError: 568 t, e, traceback = sys.exc_info() 569if e.retcode ==1: 570# "the section or key is invalid"; i.e., there is no 571# value for the specified key. 572return default 573else: 574raise 575 576defset(self, name, value): 577read_git_output( 578['config','%s.%s'% (self.section, name), value], 579 env=self.env, 580) 581 582defadd(self, name, value): 583read_git_output( 584['config','--add','%s.%s'% (self.section, name), value], 585 env=self.env, 586) 587 588def__contains__(self, name): 589return self.get_all(name, default=None)is not None 590 591# We don't use this method anymore internally, but keep it here in 592# case somebody is calling it from their own code: 593defhas_key(self, name): 594return name in self 595 596defunset_all(self, name): 597try: 598read_git_output( 599['config','--unset-all','%s.%s'% (self.section, name)], 600 env=self.env, 601) 602except CommandError: 603 t, e, traceback = sys.exc_info() 604if e.retcode ==5: 605# The name doesn't exist, which is what we wanted anyway... 606pass 607else: 608raise 609 610defset_recipients(self, name, value): 611 self.unset_all(name) 612for pair ingetaddresses([value]): 613 self.add(name,formataddr(pair)) 614 615 616defgenerate_summaries(*log_args): 617"""Generate a brief summary for each revision requested. 618 619 log_args are strings that will be passed directly to "git log" as 620 revision selectors. Iterate over (sha1_short, subject) for each 621 commit specified by log_args (subject is the first line of the 622 commit message as a string without EOLs).""" 623 624 cmd = [ 625'log','--abbrev','--format=%h%s', 626] +list(log_args) + ['--'] 627for line inread_git_lines(cmd): 628yieldtuple(line.split(' ',1)) 629 630 631deflimit_lines(lines, max_lines): 632for(index, line)inenumerate(lines): 633if index < max_lines: 634yield line 635 636if index >= max_lines: 637yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 638 639 640deflimit_linelength(lines, max_linelength): 641for line in lines: 642# Don't forget that lines always include a trailing newline. 643iflen(line) > max_linelength +1: 644 line = line[:max_linelength -7] +' [...]\n' 645yield line 646 647 648classCommitSet(object): 649"""A (constant) set of object names. 650 651 The set should be initialized with full SHA1 object names. The 652 __contains__() method returns True iff its argument is an 653 abbreviation of any the names in the set.""" 654 655def__init__(self, names): 656 self._names =sorted(names) 657 658def__len__(self): 659returnlen(self._names) 660 661def__contains__(self, sha1_abbrev): 662"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 663 664 i = bisect.bisect_left(self._names, sha1_abbrev) 665return i <len(self)and self._names[i].startswith(sha1_abbrev) 666 667 668classGitObject(object): 669def__init__(self, sha1,type=None): 670if sha1 == ZEROS: 671 self.sha1 = self.type= self.commit_sha1 =None 672else: 673 self.sha1 = sha1 674 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 675 676if self.type=='commit': 677 self.commit_sha1 = self.sha1 678elif self.type=='tag': 679try: 680 self.commit_sha1 =read_git_output( 681['rev-parse','--verify','%s^0'% (self.sha1,)] 682) 683except CommandError: 684# Cannot deref tag to determine commit_sha1 685 self.commit_sha1 =None 686else: 687 self.commit_sha1 =None 688 689 self.short =read_git_output(['rev-parse','--short', sha1]) 690 691defget_summary(self): 692"""Return (sha1_short, subject) for this commit.""" 693 694if not self.sha1: 695raiseValueError('Empty commit has no summary') 696 697returnnext(iter(generate_summaries('--no-walk', self.sha1))) 698 699def__eq__(self, other): 700returnisinstance(other, GitObject)and self.sha1 == other.sha1 701 702def__hash__(self): 703returnhash(self.sha1) 704 705def__nonzero__(self): 706returnbool(self.sha1) 707 708def__bool__(self): 709"""Python 2 backward compatibility""" 710return self.__nonzero__() 711 712def__str__(self): 713return self.sha1 or ZEROS 714 715 716classChange(object): 717"""A Change that has been made to the Git repository. 718 719 Abstract class from which both Revisions and ReferenceChanges are 720 derived. A Change knows how to generate a notification email 721 describing itself.""" 722 723def__init__(self, environment): 724 self.environment = environment 725 self._values =None 726 self._contains_html_diff =False 727 728def_contains_diff(self): 729# We do contain a diff, should it be rendered in HTML? 730if self.environment.commit_email_format =="html": 731 self._contains_html_diff =True 732 733def_compute_values(self): 734"""Return a dictionary{keyword: expansion}for this Change. 735 736 Derived classes overload this method to add more entries to 737 the return value. This method is used internally by 738 get_values(). The return value should always be a new 739 dictionary.""" 740 741 values = self.environment.get_values() 742 fromaddr = self.environment.get_fromaddr(change=self) 743if fromaddr is not None: 744 values['fromaddr'] = fromaddr 745 values['multimail_version'] =get_version() 746return values 747 748defget_values(self, **extra_values): 749"""Return a dictionary{keyword: expansion}for this Change. 750 751 Return a dictionary mapping keywords to the values that they 752 should be expanded to for this Change (used when interpolating 753 template strings). If any keyword arguments are supplied, add 754 those to the return value as well. The return value is always 755 a new dictionary.""" 756 757if self._values is None: 758 self._values = self._compute_values() 759 760 values = self._values.copy() 761if extra_values: 762 values.update(extra_values) 763return values 764 765defexpand(self, template, **extra_values): 766"""Expand template. 767 768 Expand the template (which should be a string) using string 769 interpolation of the values for this Change. If any keyword 770 arguments are provided, also include those in the keywords 771 available for interpolation.""" 772 773return template % self.get_values(**extra_values) 774 775defexpand_lines(self, template, **extra_values): 776"""Break template into lines and expand each line.""" 777 778 values = self.get_values(**extra_values) 779for line in template.splitlines(True): 780yield line % values 781 782defexpand_header_lines(self, template, **extra_values): 783"""Break template into lines and expand each line as an RFC 2822 header. 784 785 Encode values and split up lines that are too long. Silently 786 skip lines that contain references to unknown variables.""" 787 788 values = self.get_values(**extra_values) 789if self._contains_html_diff: 790 values['contenttype'] ='html' 791else: 792 values['contenttype'] ='plain' 793 794for line in template.splitlines(): 795(name, value) = line.split(': ',1) 796 797try: 798 value = value % values 799exceptKeyError: 800 t, e, traceback = sys.exc_info() 801if DEBUG: 802 self.environment.log_warning( 803'Warning: unknown variable%rin the following line; line skipped:\n' 804'%s\n' 805% (e.args[0], line,) 806) 807else: 808if name.lower()in ADDR_HEADERS: 809 value =addr_header_encode(value, name) 810else: 811 value =header_encode(value, name) 812for splitline in('%s:%s\n'% (name, value)).splitlines(True): 813yield splitline 814 815defgenerate_email_header(self): 816"""Generate the RFC 2822 email headers for this Change, a line at a time. 817 818 The output should not include the trailing blank line.""" 819 820raiseNotImplementedError() 821 822defgenerate_email_intro(self): 823"""Generate the email intro for this Change, a line at a time. 824 825 The output will be used as the standard boilerplate at the top 826 of the email body.""" 827 828raiseNotImplementedError() 829 830defgenerate_email_body(self): 831"""Generate the main part of the email body, a line at a time. 832 833 The text in the body might be truncated after a specified 834 number of lines (see multimailhook.emailmaxlines).""" 835 836raiseNotImplementedError() 837 838defgenerate_email_footer(self): 839"""Generate the footer of the email, a line at a time. 840 841 The footer is always included, irrespective of 842 multimailhook.emailmaxlines.""" 843 844raiseNotImplementedError() 845 846def_wrap_for_html(self, lines): 847"""Wrap the lines in HTML <pre> tag when using HTML format. 848 849 Escape special HTML characters and add <pre> and </pre> tags around 850 the given lines if we should be generating HTML as indicated by 851 self._contains_html_diff being set to true. 852 """ 853if self._contains_html_diff: 854yield"<pre style='margin:0'>\n" 855 856for line in lines: 857yield cgi.escape(line) 858 859yield'</pre>\n' 860else: 861for line in lines: 862yield line 863 864defgenerate_email(self, push, body_filter=None, extra_header_values={}): 865"""Generate an email describing this change. 866 867 Iterate over the lines (including the header lines) of an 868 email describing this change. If body_filter is not None, 869 then use it to filter the lines that are intended for the 870 email body. 871 872 The extra_header_values field is received as a dict and not as 873 **kwargs, to allow passing other keyword arguments in the 874 future (e.g. passing extra values to generate_email_intro()""" 875 876for line in self.generate_email_header(**extra_header_values): 877yield line 878yield'\n' 879for line in self._wrap_for_html(self.generate_email_intro()): 880yield line 881 882 body = self.generate_email_body(push) 883if body_filter is not None: 884 body =body_filter(body) 885 886 diff_started =False 887if self._contains_html_diff: 888# "white-space: pre" is the default, but we need to 889# specify it again in case the message is viewed in a 890# webmail which wraps it in an element setting white-space 891# to something else (Zimbra does this and sets 892# white-space: pre-line). 893yield'<pre style="white-space: pre; background: #F8F8F8">' 894for line in body: 895if self._contains_html_diff: 896# This is very, very naive. It would be much better to really 897# parse the diff, i.e. look at how many lines do we have in 898# the hunk headers instead of blindly highlighting everything 899# that looks like it might be part of a diff. 900 bgcolor ='' 901 fgcolor ='' 902if line.startswith('--- a/'): 903 diff_started =True 904 bgcolor ='e0e0ff' 905elif line.startswith('diff ')or line.startswith('index '): 906 diff_started =True 907 fgcolor ='808080' 908elif diff_started: 909if line.startswith('+++ '): 910 bgcolor ='e0e0ff' 911elif line.startswith('@@'): 912 bgcolor ='e0e0e0' 913elif line.startswith('+'): 914 bgcolor ='e0ffe0' 915elif line.startswith('-'): 916 bgcolor ='ffe0e0' 917elif line.startswith('commit '): 918 fgcolor ='808000' 919elif line.startswith(' '): 920 fgcolor ='404040' 921 922# Chop the trailing LF, we don't want it inside <pre>. 923 line = cgi.escape(line[:-1]) 924 925if bgcolor or fgcolor: 926 style ='display:block; white-space:pre;' 927if bgcolor: 928 style +='background:#'+ bgcolor +';' 929if fgcolor: 930 style +='color:#'+ fgcolor +';' 931# Use a <span style='display:block> to color the 932# whole line. The newline must be inside the span 933# to display properly both in Firefox and in 934# text-based browser. 935 line ="<span style='%s'>%s\n</span>"% (style, line) 936else: 937 line = line +'\n' 938 939yield line 940if self._contains_html_diff: 941yield'</pre>' 942 943for line in self._wrap_for_html(self.generate_email_footer()): 944yield line 945 946defget_alt_fromaddr(self): 947return None 948 949 950classRevision(Change): 951"""A Change consisting of a single git commit.""" 952 953 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$') 954 955def__init__(self, reference_change, rev, num, tot): 956 Change.__init__(self, reference_change.environment) 957 self.reference_change = reference_change 958 self.rev = rev 959 self.change_type = self.reference_change.change_type 960 self.refname = self.reference_change.refname 961 self.num = num 962 self.tot = tot 963 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1]) 964 self.recipients = self.environment.get_revision_recipients(self) 965 966 self.cc_recipients ='' 967if self.environment.get_scancommitforcc(): 968 self.cc_recipients =', '.join(to.strip()for to in self._cc_recipients()) 969if self.cc_recipients: 970 self.environment.log_msg( 971'Add%sto CC for%s\n'% (self.cc_recipients, self.rev.sha1)) 972 973def_cc_recipients(self): 974 cc_recipients = [] 975 message =read_git_output(['log','--no-walk','--format=%b', self.rev.sha1]) 976 lines = message.strip().split('\n') 977for line in lines: 978 m = re.match(self.CC_RE, line) 979if m: 980 cc_recipients.append(m.group('to')) 981 982return cc_recipients 983 984def_compute_values(self): 985 values = Change._compute_values(self) 986 987 oneline =read_git_output( 988['log','--format=%s','--no-walk', self.rev.sha1] 989) 990 991 values['rev'] = self.rev.sha1 992 values['rev_short'] = self.rev.short 993 values['change_type'] = self.change_type 994 values['refname'] = self.refname 995 values['short_refname'] = self.reference_change.short_refname 996 values['refname_type'] = self.reference_change.refname_type 997 values['reply_to_msgid'] = self.reference_change.msgid 998 values['num'] = self.num 999 values['tot'] = self.tot1000 values['recipients'] = self.recipients1001if self.cc_recipients:1002 values['cc_recipients'] = self.cc_recipients1003 values['oneline'] = oneline1004 values['author'] = self.author10051006 reply_to = self.environment.get_reply_to_commit(self)1007if reply_to:1008 values['reply_to'] = reply_to10091010return values10111012defgenerate_email_header(self, **extra_values):1013for line in self.expand_header_lines(1014 REVISION_HEADER_TEMPLATE, **extra_values1015):1016yield line10171018defgenerate_email_intro(self):1019for line in self.expand_lines(REVISION_INTRO_TEMPLATE):1020yield line10211022defgenerate_email_body(self, push):1023"""Show this revision."""10241025for line inread_git_lines(1026['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],1027 keepends=True,1028):1029if line.startswith('Date: ')and self.environment.date_substitute:1030yield self.environment.date_substitute + line[len('Date: '):]1031else:1032yield line10331034defgenerate_email_footer(self):1035return self.expand_lines(REVISION_FOOTER_TEMPLATE)10361037defgenerate_email(self, push, body_filter=None, extra_header_values={}):1038 self._contains_diff()1039return Change.generate_email(self, push, body_filter, extra_header_values)10401041defget_alt_fromaddr(self):1042return self.environment.from_commit104310441045classReferenceChange(Change):1046"""A Change to a Git reference.10471048 An abstract class representing a create, update, or delete of a1049 Git reference. Derived classes handle specific types of reference1050 (e.g., tags vs. branches). These classes generate the main1051 reference change email summarizing the reference change and1052 whether it caused any any commits to be added or removed.10531054 ReferenceChange objects are usually created using the static1055 create() method, which has the logic to decide which derived class1056 to instantiate."""10571058 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')10591060@staticmethod1061defcreate(environment, oldrev, newrev, refname):1062"""Return a ReferenceChange object representing the change.10631064 Return an object that represents the type of change that is being1065 made. oldrev and newrev should be SHA1s or ZEROS."""10661067 old =GitObject(oldrev)1068 new =GitObject(newrev)1069 rev = new or old10701071# The revision type tells us what type the commit is, combined with1072# the location of the ref we can decide between1073# - working branch1074# - tracking branch1075# - unannotated tag1076# - annotated tag1077 m = ReferenceChange.REF_RE.match(refname)1078if m:1079 area = m.group('area')1080 short_refname = m.group('shortname')1081else:1082 area =''1083 short_refname = refname10841085if rev.type=='tag':1086# Annotated tag:1087 klass = AnnotatedTagChange1088elif rev.type=='commit':1089if area =='tags':1090# Non-annotated tag:1091 klass = NonAnnotatedTagChange1092elif area =='heads':1093# Branch:1094 klass = BranchChange1095elif area =='remotes':1096# Tracking branch:1097 environment.log_warning(1098'*** Push-update of tracking branch%r\n'1099'*** - incomplete email generated.\n'1100% (refname,)1101)1102 klass = OtherReferenceChange1103else:1104# Some other reference namespace:1105 environment.log_warning(1106'*** Push-update of strange reference%r\n'1107'*** - incomplete email generated.\n'1108% (refname,)1109)1110 klass = OtherReferenceChange1111else:1112# Anything else (is there anything else?)1113 environment.log_warning(1114'*** Unknown type of update to%r(%s)\n'1115'*** - incomplete email generated.\n'1116% (refname, rev.type,)1117)1118 klass = OtherReferenceChange11191120returnklass(1121 environment,1122 refname=refname, short_refname=short_refname,1123 old=old, new=new, rev=rev,1124)11251126def__init__(self, environment, refname, short_refname, old, new, rev):1127 Change.__init__(self, environment)1128 self.change_type = {1129(False,True):'create',1130(True,True):'update',1131(True,False):'delete',1132}[bool(old),bool(new)]1133 self.refname = refname1134 self.short_refname = short_refname1135 self.old = old1136 self.new = new1137 self.rev = rev1138 self.msgid =make_msgid()1139 self.diffopts = environment.diffopts1140 self.graphopts = environment.graphopts1141 self.logopts = environment.logopts1142 self.commitlogopts = environment.commitlogopts1143 self.showgraph = environment.refchange_showgraph1144 self.showlog = environment.refchange_showlog11451146 self.header_template = REFCHANGE_HEADER_TEMPLATE1147 self.intro_template = REFCHANGE_INTRO_TEMPLATE1148 self.footer_template = FOOTER_TEMPLATE11491150def_compute_values(self):1151 values = Change._compute_values(self)11521153 values['change_type'] = self.change_type1154 values['refname_type'] = self.refname_type1155 values['refname'] = self.refname1156 values['short_refname'] = self.short_refname1157 values['msgid'] = self.msgid1158 values['recipients'] = self.recipients1159 values['oldrev'] =str(self.old)1160 values['oldrev_short'] = self.old.short1161 values['newrev'] =str(self.new)1162 values['newrev_short'] = self.new.short11631164if self.old:1165 values['oldrev_type'] = self.old.type1166if self.new:1167 values['newrev_type'] = self.new.type11681169 reply_to = self.environment.get_reply_to_refchange(self)1170if reply_to:1171 values['reply_to'] = reply_to11721173return values11741175defsend_single_combined_email(self, known_added_sha1s):1176"""Determine if a combined refchange/revision email should be sent11771178 If there is only a single new (non-merge) commit added by a1179 change, it is useful to combine the ReferenceChange and1180 Revision emails into one. In such a case, return the single1181 revision; otherwise, return None.11821183 This method is overridden in BranchChange."""11841185return None11861187defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1188"""Generate an email describing this change AND specified revision.11891190 Iterate over the lines (including the header lines) of an1191 email describing this change. If body_filter is not None,1192 then use it to filter the lines that are intended for the1193 email body.11941195 The extra_header_values field is received as a dict and not as1196 **kwargs, to allow passing other keyword arguments in the1197 future (e.g. passing extra values to generate_email_intro()11981199 This method is overridden in BranchChange."""12001201raiseNotImplementedError12021203defget_subject(self):1204 template = {1205'create': REF_CREATED_SUBJECT_TEMPLATE,1206'update': REF_UPDATED_SUBJECT_TEMPLATE,1207'delete': REF_DELETED_SUBJECT_TEMPLATE,1208}[self.change_type]1209return self.expand(template)12101211defgenerate_email_header(self, **extra_values):1212if'subject'not in extra_values:1213 extra_values['subject'] = self.get_subject()12141215for line in self.expand_header_lines(1216 self.header_template, **extra_values1217):1218yield line12191220defgenerate_email_intro(self):1221for line in self.expand_lines(self.intro_template):1222yield line12231224defgenerate_email_body(self, push):1225"""Call the appropriate body-generation routine.12261227 Call one of generate_create_summary() /1228 generate_update_summary() / generate_delete_summary()."""12291230 change_summary = {1231'create': self.generate_create_summary,1232'delete': self.generate_delete_summary,1233'update': self.generate_update_summary,1234}[self.change_type](push)1235for line in change_summary:1236yield line12371238for line in self.generate_revision_change_summary(push):1239yield line12401241defgenerate_email_footer(self):1242return self.expand_lines(self.footer_template)12431244defgenerate_revision_change_graph(self, push):1245if self.showgraph:1246 args = ['--graph'] + self.graphopts1247for newold in('new','old'):1248 has_newold =False1249 spec = push.get_commits_spec(newold, self)1250for line ingit_log(spec, args=args, keepends=True):1251if not has_newold:1252 has_newold =True1253yield'\n'1254yield'Graph of%scommits:\n\n'% (1255 {'new': 'new', 'old': 'discarded'}[newold],)1256yield' '+ line1257if has_newold:1258yield'\n'12591260defgenerate_revision_change_log(self, new_commits_list):1261if self.showlog:1262yield'\n'1263yield'Detailed log of new commits:\n\n'1264for line inread_git_lines(1265['log','--no-walk'] +1266 self.logopts +1267 new_commits_list +1268['--'],1269 keepends=True,1270):1271yield line12721273defgenerate_new_revision_summary(self, tot, new_commits_list, push):1274for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):1275yield line1276for line in self.generate_revision_change_graph(push):1277yield line1278for line in self.generate_revision_change_log(new_commits_list):1279yield line12801281defgenerate_revision_change_summary(self, push):1282"""Generate a summary of the revisions added/removed by this change."""12831284if self.new.commit_sha1 and not self.old.commit_sha1:1285# A new reference was created. List the new revisions1286# brought by the new reference (i.e., those revisions that1287# were not in the repository before this reference1288# change).1289 sha1s =list(push.get_new_commits(self))1290 sha1s.reverse()1291 tot =len(sha1s)1292 new_revisions = [1293Revision(self,GitObject(sha1), num=i +1, tot=tot)1294for(i, sha1)inenumerate(sha1s)1295]12961297if new_revisions:1298yield self.expand('This%(refname_type)sincludes the following new commits:\n')1299yield'\n'1300for r in new_revisions:1301(sha1, subject) = r.rev.get_summary()1302yield r.expand(1303 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,1304)1305yield'\n'1306for line in self.generate_new_revision_summary(1307 tot, [r.rev.sha1 for r in new_revisions], push):1308yield line1309else:1310for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1311yield line13121313elif self.new.commit_sha1 and self.old.commit_sha1:1314# A reference was changed to point at a different commit.1315# List the revisions that were removed and/or added *from1316# that reference* by this reference change, along with a1317# diff between the trees for its old and new values.13181319# List of the revisions that were added to the branch by1320# this update. Note this list can include revisions that1321# have already had notification emails; we want such1322# revisions in the summary even though we will not send1323# new notification emails for them.1324 adds =list(generate_summaries(1325'--topo-order','--reverse','%s..%s'1326% (self.old.commit_sha1, self.new.commit_sha1,)1327))13281329# List of the revisions that were removed from the branch1330# by this update. This will be empty except for1331# non-fast-forward updates.1332 discards =list(generate_summaries(1333'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,)1334))13351336if adds:1337 new_commits_list = push.get_new_commits(self)1338else:1339 new_commits_list = []1340 new_commits =CommitSet(new_commits_list)13411342if discards:1343 discarded_commits =CommitSet(push.get_discarded_commits(self))1344else:1345 discarded_commits =CommitSet([])13461347if discards and adds:1348for(sha1, subject)in discards:1349if sha1 in discarded_commits:1350 action ='discards'1351else:1352 action ='omits'1353yield self.expand(1354 BRIEF_SUMMARY_TEMPLATE, action=action,1355 rev_short=sha1, text=subject,1356)1357for(sha1, subject)in adds:1358if sha1 in new_commits:1359 action ='new'1360else:1361 action ='adds'1362yield self.expand(1363 BRIEF_SUMMARY_TEMPLATE, action=action,1364 rev_short=sha1, text=subject,1365)1366yield'\n'1367for line in self.expand_lines(NON_FF_TEMPLATE):1368yield line13691370elif discards:1371for(sha1, subject)in discards:1372if sha1 in discarded_commits:1373 action ='discards'1374else:1375 action ='omits'1376yield self.expand(1377 BRIEF_SUMMARY_TEMPLATE, action=action,1378 rev_short=sha1, text=subject,1379)1380yield'\n'1381for line in self.expand_lines(REWIND_ONLY_TEMPLATE):1382yield line13831384elif adds:1385(sha1, subject) = self.old.get_summary()1386yield self.expand(1387 BRIEF_SUMMARY_TEMPLATE, action='from',1388 rev_short=sha1, text=subject,1389)1390for(sha1, subject)in adds:1391if sha1 in new_commits:1392 action ='new'1393else:1394 action ='adds'1395yield self.expand(1396 BRIEF_SUMMARY_TEMPLATE, action=action,1397 rev_short=sha1, text=subject,1398)13991400yield'\n'14011402if new_commits:1403for line in self.generate_new_revision_summary(1404len(new_commits), new_commits_list, push):1405yield line1406else:1407for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1408yield line1409for line in self.generate_revision_change_graph(push):1410yield line14111412# The diffstat is shown from the old revision to the new1413# revision. This is to show the truth of what happened in1414# this change. There's no point showing the stat from the1415# base to the new revision because the base is effectively a1416# random revision at this point - the user will be interested1417# in what this revision changed - including the undoing of1418# previous revisions in the case of non-fast-forward updates.1419yield'\n'1420yield'Summary of changes:\n'1421for line inread_git_lines(1422['diff-tree'] +1423 self.diffopts +1424['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1425 keepends=True,1426):1427yield line14281429elif self.old.commit_sha1 and not self.new.commit_sha1:1430# A reference was deleted. List the revisions that were1431# removed from the repository by this reference change.14321433 sha1s =list(push.get_discarded_commits(self))1434 tot =len(sha1s)1435 discarded_revisions = [1436Revision(self,GitObject(sha1), num=i +1, tot=tot)1437for(i, sha1)inenumerate(sha1s)1438]14391440if discarded_revisions:1441for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1442yield line1443yield'\n'1444for r in discarded_revisions:1445(sha1, subject) = r.rev.get_summary()1446yield r.expand(1447 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,1448)1449for line in self.generate_revision_change_graph(push):1450yield line1451else:1452for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1453yield line14541455elif not self.old.commit_sha1 and not self.new.commit_sha1:1456for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1457yield line14581459defgenerate_create_summary(self, push):1460"""Called for the creation of a reference."""14611462# This is a new reference and so oldrev is not valid1463(sha1, subject) = self.new.get_summary()1464yield self.expand(1465 BRIEF_SUMMARY_TEMPLATE, action='at',1466 rev_short=sha1, text=subject,1467)1468yield'\n'14691470defgenerate_update_summary(self, push):1471"""Called for the change of a pre-existing branch."""14721473returniter([])14741475defgenerate_delete_summary(self, push):1476"""Called for the deletion of any type of reference."""14771478(sha1, subject) = self.old.get_summary()1479yield self.expand(1480 BRIEF_SUMMARY_TEMPLATE, action='was',1481 rev_short=sha1, text=subject,1482)1483yield'\n'14841485defget_alt_fromaddr(self):1486return self.environment.from_refchange148714881489classBranchChange(ReferenceChange):1490 refname_type ='branch'14911492def__init__(self, environment, refname, short_refname, old, new, rev):1493 ReferenceChange.__init__(1494 self, environment,1495 refname=refname, short_refname=short_refname,1496 old=old, new=new, rev=rev,1497)1498 self.recipients = environment.get_refchange_recipients(self)1499 self._single_revision =None15001501defsend_single_combined_email(self, known_added_sha1s):1502if not self.environment.combine_when_single_commit:1503return None15041505# In the sadly-all-too-frequent usecase of people pushing only1506# one of their commits at a time to a repository, users feel1507# the reference change summary emails are noise rather than1508# important signal. This is because, in this particular1509# usecase, there is a reference change summary email for each1510# new commit, and all these summaries do is point out that1511# there is one new commit (which can readily be inferred by1512# the existence of the individual revision email that is also1513# sent). In such cases, our users prefer there to be a combined1514# reference change summary/new revision email.1515#1516# So, if the change is an update and it doesn't discard any1517# commits, and it adds exactly one non-merge commit (gerrit1518# forces a workflow where every commit is individually merged1519# and the git-multimail hook fired off for just this one1520# change), then we send a combined refchange/revision email.1521try:1522# If this change is a reference update that doesn't discard1523# any commits...1524if self.change_type !='update':1525return None15261527ifread_git_lines(1528['merge-base', self.old.sha1, self.new.sha1]1529) != [self.old.sha1]:1530return None15311532# Check if this update introduced exactly one non-merge1533# commit:15341535defsplit_line(line):1536"""Split line into (sha1, [parent,...])."""15371538 words = line.split()1539return(words[0], words[1:])15401541# Get the new commits introduced by the push as a list of1542# (sha1, [parent,...])1543 new_commits = [1544split_line(line)1545for line inread_git_lines(1546[1547'log','-3','--format=%H %P',1548'%s..%s'% (self.old.sha1, self.new.sha1),1549]1550)1551]15521553if not new_commits:1554return None15551556# If the newest commit is a merge, save it for a later check1557# but otherwise ignore it1558 merge =None1559 tot =len(new_commits)1560iflen(new_commits[0][1]) >1:1561 merge = new_commits[0][0]1562del new_commits[0]15631564# Our primary check: we can't combine if more than one commit1565# is introduced. We also currently only combine if the new1566# commit is a non-merge commit, though it may make sense to1567# combine if it is a merge as well.1568if not(1569len(new_commits) ==1and1570len(new_commits[0][1]) ==1and1571 new_commits[0][0]in known_added_sha1s1572):1573return None15741575# We do not want to combine revision and refchange emails if1576# those go to separate locations.1577 rev =Revision(self,GitObject(new_commits[0][0]),1, tot)1578if rev.recipients != self.recipients:1579return None15801581# We ignored the newest commit if it was just a merge of the one1582# commit being introduced. But we don't want to ignore that1583# merge commit it it involved conflict resolutions. Check that.1584if merge and merge !=read_git_output(['diff-tree','--cc', merge]):1585return None15861587# We can combine the refchange and one new revision emails1588# into one. Return the Revision that a combined email should1589# be sent about.1590return rev1591except CommandError:1592# Cannot determine number of commits in old..new or new..old;1593# don't combine reference/revision emails:1594return None15951596defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1597 values = revision.get_values()1598if extra_header_values:1599 values.update(extra_header_values)1600if'subject'not in extra_header_values:1601 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)16021603 self._single_revision = revision1604 self._contains_diff()1605 self.header_template = COMBINED_HEADER_TEMPLATE1606 self.intro_template = COMBINED_INTRO_TEMPLATE1607 self.footer_template = COMBINED_FOOTER_TEMPLATE1608for line in self.generate_email(push, body_filter, values):1609yield line16101611defgenerate_email_body(self, push):1612'''Call the appropriate body generation routine.16131614 If this is a combined refchange/revision email, the special logic1615 for handling this combined email comes from this function. For1616 other cases, we just use the normal handling.'''16171618# If self._single_revision isn't set; don't override1619if not self._single_revision:1620for line insuper(BranchChange, self).generate_email_body(push):1621yield line1622return16231624# This is a combined refchange/revision email; we first provide1625# some info from the refchange portion, and then call the revision1626# generate_email_body function to handle the revision portion.1627 adds =list(generate_summaries(1628'--topo-order','--reverse','%s..%s'1629% (self.old.commit_sha1, self.new.commit_sha1,)1630))16311632yield self.expand("The following commit(s) were added to%(refname)sby this push:\n")1633for(sha1, subject)in adds:1634yield self.expand(1635 BRIEF_SUMMARY_TEMPLATE, action='new',1636 rev_short=sha1, text=subject,1637)16381639yield self._single_revision.rev.short +" is described below\n"1640yield'\n'16411642for line in self._single_revision.generate_email_body(push):1643yield line164416451646classAnnotatedTagChange(ReferenceChange):1647 refname_type ='annotated tag'16481649def__init__(self, environment, refname, short_refname, old, new, rev):1650 ReferenceChange.__init__(1651 self, environment,1652 refname=refname, short_refname=short_refname,1653 old=old, new=new, rev=rev,1654)1655 self.recipients = environment.get_announce_recipients(self)1656 self.show_shortlog = environment.announce_show_shortlog16571658 ANNOTATED_TAG_FORMAT = (1659'%(*objectname)\n'1660'%(*objecttype)\n'1661'%(taggername)\n'1662'%(taggerdate)'1663)16641665defdescribe_tag(self, push):1666"""Describe the new value of an annotated tag."""16671668# Use git for-each-ref to pull out the individual fields from1669# the tag1670[tagobject, tagtype, tagger, tagged] =read_git_lines(1671['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1672)16731674yield self.expand(1675 BRIEF_SUMMARY_TEMPLATE, action='tagging',1676 rev_short=tagobject, text='(%s)'% (tagtype,),1677)1678if tagtype =='commit':1679# If the tagged object is a commit, then we assume this is a1680# release, and so we calculate which tag this tag is1681# replacing1682try:1683 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1684except CommandError:1685 prevtag =None1686if prevtag:1687yield' replaces%s\n'% (prevtag,)1688else:1689 prevtag =None1690yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)16911692yield' tagged by%s\n'% (tagger,)1693yield' on%s\n'% (tagged,)1694yield'\n'16951696# Show the content of the tag message; this might contain a1697# change log or release notes so is worth displaying.1698yield LOGBEGIN1699 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1700 contents = contents[contents.index('\n') +1:]1701if contents and contents[-1][-1:] !='\n':1702 contents.append('\n')1703for line in contents:1704yield line17051706if self.show_shortlog and tagtype =='commit':1707# Only commit tags make sense to have rev-list operations1708# performed on them1709yield'\n'1710if prevtag:1711# Show changes since the previous release1712 revlist =read_git_output(1713['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1714 keepends=True,1715)1716else:1717# No previous tag, show all the changes since time1718# began1719 revlist =read_git_output(1720['rev-list','--pretty=short','%s'% (self.new,)],1721 keepends=True,1722)1723for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1724yield line17251726yield LOGEND1727yield'\n'17281729defgenerate_create_summary(self, push):1730"""Called for the creation of an annotated tag."""17311732for line in self.expand_lines(TAG_CREATED_TEMPLATE):1733yield line17341735for line in self.describe_tag(push):1736yield line17371738defgenerate_update_summary(self, push):1739"""Called for the update of an annotated tag.17401741 This is probably a rare event and may not even be allowed."""17421743for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1744yield line17451746for line in self.describe_tag(push):1747yield line17481749defgenerate_delete_summary(self, push):1750"""Called when a non-annotated reference is updated."""17511752for line in self.expand_lines(TAG_DELETED_TEMPLATE):1753yield line17541755yield self.expand(' tag was%(oldrev_short)s\n')1756yield'\n'175717581759classNonAnnotatedTagChange(ReferenceChange):1760 refname_type ='tag'17611762def__init__(self, environment, refname, short_refname, old, new, rev):1763 ReferenceChange.__init__(1764 self, environment,1765 refname=refname, short_refname=short_refname,1766 old=old, new=new, rev=rev,1767)1768 self.recipients = environment.get_refchange_recipients(self)17691770defgenerate_create_summary(self, push):1771"""Called for the creation of an annotated tag."""17721773for line in self.expand_lines(TAG_CREATED_TEMPLATE):1774yield line17751776defgenerate_update_summary(self, push):1777"""Called when a non-annotated reference is updated."""17781779for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1780yield line17811782defgenerate_delete_summary(self, push):1783"""Called when a non-annotated reference is updated."""17841785for line in self.expand_lines(TAG_DELETED_TEMPLATE):1786yield line17871788for line in ReferenceChange.generate_delete_summary(self, push):1789yield line179017911792classOtherReferenceChange(ReferenceChange):1793 refname_type ='reference'17941795def__init__(self, environment, refname, short_refname, old, new, rev):1796# We use the full refname as short_refname, because otherwise1797# the full name of the reference would not be obvious from the1798# text of the email.1799 ReferenceChange.__init__(1800 self, environment,1801 refname=refname, short_refname=refname,1802 old=old, new=new, rev=rev,1803)1804 self.recipients = environment.get_refchange_recipients(self)180518061807classMailer(object):1808"""An object that can send emails."""18091810defsend(self, lines, to_addrs):1811"""Send an email consisting of lines.18121813 lines must be an iterable over the lines constituting the1814 header and body of the email. to_addrs is a list of recipient1815 addresses (can be needed even if lines already contains a1816 "To:" field). It can be either a string (comma-separated list1817 of email addresses) or a Python list of individual email1818 addresses.18191820 """18211822raiseNotImplementedError()182318241825classSendMailer(Mailer):1826"""Send emails using 'sendmail -oi -t'."""18271828 SENDMAIL_CANDIDATES = [1829'/usr/sbin/sendmail',1830'/usr/lib/sendmail',1831]18321833@staticmethod1834deffind_sendmail():1835for path in SendMailer.SENDMAIL_CANDIDATES:1836if os.access(path, os.X_OK):1837return path1838else:1839raiseConfigurationException(1840'No sendmail executable found. '1841'Try setting multimailhook.sendmailCommand.'1842)18431844def__init__(self, command=None, envelopesender=None):1845"""Construct a SendMailer instance.18461847 command should be the command and arguments used to invoke1848 sendmail, as a list of strings. If an envelopesender is1849 provided, it will also be passed to the command, via '-f1850 envelopesender'."""18511852if command:1853 self.command = command[:]1854else:1855 self.command = [self.find_sendmail(),'-oi','-t']18561857if envelopesender:1858 self.command.extend(['-f', envelopesender])18591860defsend(self, lines, to_addrs):1861try:1862 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)1863exceptOSError:1864 sys.stderr.write(1865'*** Cannot execute command:%s\n'%' '.join(self.command) +1866'***%s\n'% sys.exc_info()[1] +1867'*** Try setting multimailhook.mailer to "smtp"\n'+1868'*** to send emails without using the sendmail command.\n'1869)1870 sys.exit(1)1871try:1872 lines = (str_to_bytes(line)for line in lines)1873 p.stdin.writelines(lines)1874exceptException:1875 sys.stderr.write(1876'*** Error while generating commit email\n'1877'*** - mail sending aborted.\n'1878)1879try:1880# subprocess.terminate() is not available in Python 2.41881 p.terminate()1882exceptAttributeError:1883pass1884raise1885else:1886 p.stdin.close()1887 retcode = p.wait()1888if retcode:1889raiseCommandError(self.command, retcode)189018911892classSMTPMailer(Mailer):1893"""Send emails using Python's smtplib."""18941895def__init__(self, envelopesender, smtpserver,1896 smtpservertimeout=10.0, smtpserverdebuglevel=0,1897 smtpencryption='none',1898 smtpuser='', smtppass='',1899):1900if not envelopesender:1901 sys.stderr.write(1902'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'1903'please set either multimailhook.envelopeSender or user.email\n'1904)1905 sys.exit(1)1906if smtpencryption =='ssl'and not(smtpuser and smtppass):1907raiseConfigurationException(1908'Cannot use SMTPMailer with security option ssl '1909'without options username and password.'1910)1911 self.envelopesender = envelopesender1912 self.smtpserver = smtpserver1913 self.smtpservertimeout = smtpservertimeout1914 self.smtpserverdebuglevel = smtpserverdebuglevel1915 self.security = smtpencryption1916 self.username = smtpuser1917 self.password = smtppass1918try:1919defcall(klass, server, timeout):1920try:1921returnklass(server, timeout=timeout)1922exceptTypeError:1923# Old Python versions do not have timeout= argument.1924returnklass(server)1925if self.security =='none':1926 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)1927elif self.security =='ssl':1928 self.smtp =call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)1929elif self.security =='tls':1930if':'not in self.smtpserver:1931 self.smtpserver +=':587'# default port for TLS1932 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)1933 self.smtp.ehlo()1934 self.smtp.starttls()1935 self.smtp.ehlo()1936else:1937 sys.stdout.write('*** Error: Control reached an invalid option. ***')1938 sys.exit(1)1939if self.smtpserverdebuglevel >0:1940 sys.stdout.write(1941"*** Setting debug on for SMTP server connection (%s) ***\n"1942% self.smtpserverdebuglevel)1943 self.smtp.set_debuglevel(self.smtpserverdebuglevel)1944exceptException:1945 sys.stderr.write(1946'*** Error establishing SMTP connection to%s***\n'1947% self.smtpserver)1948 sys.stderr.write('***%s\n'% sys.exc_info()[1])1949 sys.exit(1)19501951def__del__(self):1952ifhasattr(self,'smtp'):1953 self.smtp.quit()19541955defsend(self, lines, to_addrs):1956try:1957if self.username or self.password:1958 self.smtp.login(self.username, self.password)1959 msg =''.join(lines)1960# turn comma-separated list into Python list if needed.1961ifisinstance(to_addrs, basestring):1962 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]1963 self.smtp.sendmail(self.envelopesender, to_addrs, msg)1964exceptException:1965 sys.stderr.write('*** Error sending email ***\n')1966 sys.stderr.write('***%s\n'% sys.exc_info()[1])1967 self.smtp.quit()1968 sys.exit(1)196919701971classOutputMailer(Mailer):1972"""Write emails to an output stream, bracketed by lines of '=' characters.19731974 This is intended for debugging purposes."""19751976 SEPARATOR ='='*75+'\n'19771978def__init__(self, f):1979 self.f = f19801981defsend(self, lines, to_addrs):1982write_str(self.f, self.SEPARATOR)1983for line in lines:1984write_str(self.f, line)1985write_str(self.f, self.SEPARATOR)198619871988defget_git_dir():1989"""Determine GIT_DIR.19901991 Determine GIT_DIR either from the GIT_DIR environment variable or1992 from the working directory, using Git's usual rules."""19931994try:1995returnread_git_output(['rev-parse','--git-dir'])1996except CommandError:1997 sys.stderr.write('fatal: git_multimail: not in a git directory\n')1998 sys.exit(1)199920002001classEnvironment(object):2002"""Describes the environment in which the push is occurring.20032004 An Environment object encapsulates information about the local2005 environment. For example, it knows how to determine:20062007 * the name of the repository to which the push occurred20082009 * what user did the push20102011 * what users want to be informed about various types of changes.20122013 An Environment object is expected to have the following methods:20142015 get_repo_shortname()20162017 Return a short name for the repository, for display2018 purposes.20192020 get_repo_path()20212022 Return the absolute path to the Git repository.20232024 get_emailprefix()20252026 Return a string that will be prefixed to every email's2027 subject.20282029 get_pusher()20302031 Return the username of the person who pushed the changes.2032 This value is used in the email body to indicate who2033 pushed the change.20342035 get_pusher_email() (may return None)20362037 Return the email address of the person who pushed the2038 changes. The value should be a single RFC 2822 email2039 address as a string; e.g., "Joe User <user@example.com>"2040 if available, otherwise "user@example.com". If set, the2041 value is used as the Reply-To address for refchange2042 emails. If it is impossible to determine the pusher's2043 email, this attribute should be set to None (in which case2044 no Reply-To header will be output).20452046 get_sender()20472048 Return the address to be used as the 'From' email address2049 in the email envelope.20502051 get_fromaddr(change=None)20522053 Return the 'From' email address used in the email 'From:'2054 headers. If the change is known when this function is2055 called, it is passed in as the 'change' parameter. (May2056 be a full RFC 2822 email address like 'Joe User2057 <user@example.com>'.)20582059 get_administrator()20602061 Return the name and/or email of the repository2062 administrator. This value is used in the footer as the2063 person to whom requests to be removed from the2064 notification list should be sent. Ideally, it should2065 include a valid email address.20662067 get_reply_to_refchange()2068 get_reply_to_commit()20692070 Return the address to use in the email "Reply-To" header,2071 as a string. These can be an RFC 2822 email address, or2072 None to omit the "Reply-To" header.2073 get_reply_to_refchange() is used for refchange emails;2074 get_reply_to_commit() is used for individual commit2075 emails.20762077 get_ref_filter_regex()20782079 Return a tuple -- a compiled regex, and a boolean indicating2080 whether the regex picks refs to include (if False, the regex2081 matches on refs to exclude).20822083 get_default_ref_ignore_regex()20842085 Return a regex that should be ignored for both what emails2086 to send and when computing what commits are considered new2087 to the repository. Default is "^refs/notes/".20882089 They should also define the following attributes:20902091 announce_show_shortlog (bool)20922093 True iff announce emails should include a shortlog.20942095 commit_email_format (string)20962097 If "html", generate commit emails in HTML instead of plain text2098 used by default.20992100 refchange_showgraph (bool)21012102 True iff refchanges emails should include a detailed graph.21032104 refchange_showlog (bool)21052106 True iff refchanges emails should include a detailed log.21072108 diffopts (list of strings)21092110 The options that should be passed to 'git diff' for the2111 summary email. The value should be a list of strings2112 representing words to be passed to the command.21132114 graphopts (list of strings)21152116 Analogous to diffopts, but contains options passed to2117 'git log --graph' when generating the detailed graph for2118 a set of commits (see refchange_showgraph)21192120 logopts (list of strings)21212122 Analogous to diffopts, but contains options passed to2123 'git log' when generating the detailed log for a set of2124 commits (see refchange_showlog)21252126 commitlogopts (list of strings)21272128 The options that should be passed to 'git log' for each2129 commit mail. The value should be a list of strings2130 representing words to be passed to the command.21312132 date_substitute (string)21332134 String to be used in substitution for 'Date:' at start of2135 line in the output of 'git log'.21362137 quiet (bool)2138 On success do not write to stderr21392140 stdout (bool)2141 Write email to stdout rather than emailing. Useful for debugging21422143 combine_when_single_commit (bool)21442145 True if a combined email should be produced when a single2146 new commit is pushed to a branch, False otherwise.21472148 from_refchange, from_commit (strings)21492150 Addresses to use for the From: field for refchange emails2151 and commit emails respectively. Set from2152 multimailhook.fromRefchange and multimailhook.fromCommit2153 by ConfigEnvironmentMixin.21542155 """21562157 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')21582159def__init__(self, osenv=None):2160 self.osenv = osenv or os.environ2161 self.announce_show_shortlog =False2162 self.commit_email_format ="text"2163 self.maxcommitemails =5002164 self.diffopts = ['--stat','--summary','--find-copies-harder']2165 self.graphopts = ['--oneline','--decorate']2166 self.logopts = []2167 self.refchange_showgraph =False2168 self.refchange_showlog =False2169 self.commitlogopts = ['-C','--stat','-p','--cc']2170 self.date_substitute ='AuthorDate: '2171 self.quiet =False2172 self.stdout =False2173 self.combine_when_single_commit =True21742175 self.COMPUTED_KEYS = [2176'administrator',2177'charset',2178'emailprefix',2179'pusher',2180'pusher_email',2181'repo_path',2182'repo_shortname',2183'sender',2184]21852186 self._values =None21872188defget_repo_shortname(self):2189"""Use the last part of the repo path, with ".git" stripped off if present."""21902191 basename = os.path.basename(os.path.abspath(self.get_repo_path()))2192 m = self.REPO_NAME_RE.match(basename)2193if m:2194return m.group('name')2195else:2196return basename21972198defget_pusher(self):2199raiseNotImplementedError()22002201defget_pusher_email(self):2202return None22032204defget_fromaddr(self, change=None):2205 config =Config('user')2206 fromname = config.get('name', default='')2207 fromemail = config.get('email', default='')2208if fromemail:2209returnformataddr([fromname, fromemail])2210return self.get_sender()22112212defget_administrator(self):2213return'the administrator of this repository'22142215defget_emailprefix(self):2216return''22172218defget_repo_path(self):2219ifread_git_output(['rev-parse','--is-bare-repository']) =='true':2220 path =get_git_dir()2221else:2222 path =read_git_output(['rev-parse','--show-toplevel'])2223return os.path.abspath(path)22242225defget_charset(self):2226return CHARSET22272228defget_values(self):2229"""Return a dictionary{keyword: expansion}for this Environment.22302231 This method is called by Change._compute_values(). The keys2232 in the returned dictionary are available to be used in any of2233 the templates. The dictionary is created by calling2234 self.get_NAME() for each of the attributes named in2235 COMPUTED_KEYS and recording those that do not return None.2236 The return value is always a new dictionary."""22372238if self._values is None:2239 values = {}22402241for key in self.COMPUTED_KEYS:2242 value =getattr(self,'get_%s'% (key,))()2243if value is not None:2244 values[key] = value22452246 self._values = values22472248return self._values.copy()22492250defget_refchange_recipients(self, refchange):2251"""Return the recipients for notifications about refchange.22522253 Return the list of email addresses to which notifications2254 about the specified ReferenceChange should be sent."""22552256raiseNotImplementedError()22572258defget_announce_recipients(self, annotated_tag_change):2259"""Return the recipients for notifications about annotated_tag_change.22602261 Return the list of email addresses to which notifications2262 about the specified AnnotatedTagChange should be sent."""22632264raiseNotImplementedError()22652266defget_reply_to_refchange(self, refchange):2267return self.get_pusher_email()22682269defget_revision_recipients(self, revision):2270"""Return the recipients for messages about revision.22712272 Return the list of email addresses to which notifications2273 about the specified Revision should be sent. This method2274 could be overridden, for example, to take into account the2275 contents of the revision when deciding whom to notify about2276 it. For example, there could be a scheme for users to express2277 interest in particular files or subdirectories, and only2278 receive notification emails for revisions that affecting those2279 files."""22802281raiseNotImplementedError()22822283defget_reply_to_commit(self, revision):2284return revision.author22852286defget_default_ref_ignore_regex(self):2287# The commit messages of git notes are essentially meaningless2288# and "filenames" in git notes commits are an implementational2289# detail that might surprise users at first. As such, we2290# would need a completely different method for handling emails2291# of git notes in order for them to be of benefit for users,2292# which we simply do not have right now.2293return"^refs/notes/"22942295deffilter_body(self, lines):2296"""Filter the lines intended for an email body.22972298 lines is an iterable over the lines that would go into the2299 email body. Filter it (e.g., limit the number of lines, the2300 line length, character set, etc.), returning another iterable.2301 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin2302 for classes implementing this functionality."""23032304return lines23052306deflog_msg(self, msg):2307"""Write the string msg on a log file or on stderr.23082309 Sends the text to stderr by default, override to change the behavior."""2310write_str(sys.stderr, msg)23112312deflog_warning(self, msg):2313"""Write the string msg on a log file or on stderr.23142315 Sends the text to stderr by default, override to change the behavior."""2316write_str(sys.stderr, msg)23172318deflog_error(self, msg):2319"""Write the string msg on a log file or on stderr.23202321 Sends the text to stderr by default, override to change the behavior."""2322write_str(sys.stderr, msg)232323242325classConfigEnvironmentMixin(Environment):2326"""A mixin that sets self.config to its constructor's config argument.23272328 This class's constructor consumes the "config" argument.23292330 Mixins that need to inspect the config should inherit from this2331 class (1) to make sure that "config" is still in the constructor2332 arguments with its own constructor runs and/or (2) to be sure that2333 self.config is set after construction."""23342335def__init__(self, config, **kw):2336super(ConfigEnvironmentMixin, self).__init__(**kw)2337 self.config = config233823392340classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):2341"""An Environment that reads most of its information from "git config"."""23422343@staticmethod2344defforbid_field_values(name, value, forbidden):2345for forbidden_val in forbidden:2346if value is not None and value.lower() == forbidden:2347raiseConfigurationException(2348'"%s" is not an allowed setting for%s'% (value, name)2349)23502351def__init__(self, config, **kw):2352super(ConfigOptionsEnvironmentMixin, self).__init__(2353 config=config, **kw2354)23552356for var, cfg in(2357('announce_show_shortlog','announceshortlog'),2358('refchange_showgraph','refchangeShowGraph'),2359('refchange_showlog','refchangeshowlog'),2360('quiet','quiet'),2361('stdout','stdout'),2362):2363 val = config.get_bool(cfg)2364if val is not None:2365setattr(self, var, val)23662367 commit_email_format = config.get('commitEmailFormat')2368if commit_email_format is not None:2369if commit_email_format !="html"and commit_email_format !="text":2370 self.log_warning(2371'*** Unknown value for multimailhook.commitEmailFormat:%s\n'%2372 commit_email_format +2373'*** Expected either "text" or "html". Ignoring.\n'2374)2375else:2376 self.commit_email_format = commit_email_format23772378 maxcommitemails = config.get('maxcommitemails')2379if maxcommitemails is not None:2380try:2381 self.maxcommitemails =int(maxcommitemails)2382exceptValueError:2383 self.log_warning(2384'*** Malformed value for multimailhook.maxCommitEmails:%s\n'2385% maxcommitemails +2386'*** Expected a number. Ignoring.\n'2387)23882389 diffopts = config.get('diffopts')2390if diffopts is not None:2391 self.diffopts = shlex.split(diffopts)23922393 graphopts = config.get('graphOpts')2394if graphopts is not None:2395 self.graphopts = shlex.split(graphopts)23962397 logopts = config.get('logopts')2398if logopts is not None:2399 self.logopts = shlex.split(logopts)24002401 commitlogopts = config.get('commitlogopts')2402if commitlogopts is not None:2403 self.commitlogopts = shlex.split(commitlogopts)24042405 date_substitute = config.get('dateSubstitute')2406if date_substitute =='none':2407 self.date_substitute =None2408elif date_substitute is not None:2409 self.date_substitute = date_substitute24102411 reply_to = config.get('replyTo')2412 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)2413 self.forbid_field_values('replyToRefchange',2414 self.__reply_to_refchange,2415['author'])2416 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)24172418 from_addr = self.config.get('from')2419 self.from_refchange = config.get('fromRefchange')2420 self.forbid_field_values('fromRefchange',2421 self.from_refchange,2422['author','none'])2423 self.from_commit = config.get('fromCommit')2424 self.forbid_field_values('fromCommit',2425 self.from_commit,2426['none'])24272428 combine = config.get_bool('combineWhenSingleCommit')2429if combine is not None:2430 self.combine_when_single_commit = combine24312432defget_administrator(self):2433return(2434 self.config.get('administrator')or2435 self.get_sender()or2436super(ConfigOptionsEnvironmentMixin, self).get_administrator()2437)24382439defget_repo_shortname(self):2440return(2441 self.config.get('reponame')or2442super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()2443)24442445defget_emailprefix(self):2446 emailprefix = self.config.get('emailprefix')2447if emailprefix is not None:2448 emailprefix = emailprefix.strip()2449if emailprefix:2450return emailprefix +' '2451else:2452return''2453else:2454return'[%s] '% (self.get_repo_shortname(),)24552456defget_sender(self):2457return self.config.get('envelopesender')24582459defprocess_addr(self, addr, change):2460if addr.lower() =='author':2461ifhasattr(change,'author'):2462return change.author2463else:2464return None2465elif addr.lower() =='pusher':2466return self.get_pusher_email()2467elif addr.lower() =='none':2468return None2469else:2470return addr24712472defget_fromaddr(self, change=None):2473 fromaddr = self.config.get('from')2474if change:2475 alt_fromaddr = change.get_alt_fromaddr()2476if alt_fromaddr:2477 fromaddr = alt_fromaddr2478if fromaddr:2479 fromaddr = self.process_addr(fromaddr, change)2480if fromaddr:2481return fromaddr2482returnsuper(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)24832484defget_reply_to_refchange(self, refchange):2485if self.__reply_to_refchange is None:2486returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)2487else:2488return self.process_addr(self.__reply_to_refchange, refchange)24892490defget_reply_to_commit(self, revision):2491if self.__reply_to_commit is None:2492returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)2493else:2494return self.process_addr(self.__reply_to_commit, revision)24952496defget_scancommitforcc(self):2497return self.config.get('scancommitforcc')249824992500classFilterLinesEnvironmentMixin(Environment):2501"""Handle encoding and maximum line length of body lines.25022503 emailmaxlinelength (int or None)25042505 The maximum length of any single line in the email body.2506 Longer lines are truncated at that length with ' [...]'2507 appended.25082509 strict_utf8 (bool)25102511 If this field is set to True, then the email body text is2512 expected to be UTF-8. Any invalid characters are2513 converted to U+FFFD, the Unicode replacement character2514 (encoded as UTF-8, of course).25152516 """25172518def__init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):2519super(FilterLinesEnvironmentMixin, self).__init__(**kw)2520 self.__strict_utf8= strict_utf82521 self.__emailmaxlinelength = emailmaxlinelength25222523deffilter_body(self, lines):2524 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)2525if self.__strict_utf8:2526if not PYTHON3:2527 lines = (line.decode(ENCODING,'replace')for line in lines)2528# Limit the line length in Unicode-space to avoid2529# splitting characters:2530if self.__emailmaxlinelength:2531 lines =limit_linelength(lines, self.__emailmaxlinelength)2532if not PYTHON3:2533 lines = (line.encode(ENCODING,'replace')for line in lines)2534elif self.__emailmaxlinelength:2535 lines =limit_linelength(lines, self.__emailmaxlinelength)25362537return lines253825392540classConfigFilterLinesEnvironmentMixin(2541 ConfigEnvironmentMixin,2542 FilterLinesEnvironmentMixin,2543):2544"""Handle encoding and maximum line length based on config."""25452546def__init__(self, config, **kw):2547 strict_utf8 = config.get_bool('emailstrictutf8', default=None)2548if strict_utf8 is not None:2549 kw['strict_utf8'] = strict_utf825502551 emailmaxlinelength = config.get('emailmaxlinelength')2552if emailmaxlinelength is not None:2553 kw['emailmaxlinelength'] =int(emailmaxlinelength)25542555super(ConfigFilterLinesEnvironmentMixin, self).__init__(2556 config=config, **kw2557)255825592560classMaxlinesEnvironmentMixin(Environment):2561"""Limit the email body to a specified number of lines."""25622563def__init__(self, emailmaxlines, **kw):2564super(MaxlinesEnvironmentMixin, self).__init__(**kw)2565 self.__emailmaxlines = emailmaxlines25662567deffilter_body(self, lines):2568 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)2569if self.__emailmaxlines:2570 lines =limit_lines(lines, self.__emailmaxlines)2571return lines257225732574classConfigMaxlinesEnvironmentMixin(2575 ConfigEnvironmentMixin,2576 MaxlinesEnvironmentMixin,2577):2578"""Limit the email body to the number of lines specified in config."""25792580def__init__(self, config, **kw):2581 emailmaxlines =int(config.get('emailmaxlines', default='0'))2582super(ConfigMaxlinesEnvironmentMixin, self).__init__(2583 config=config,2584 emailmaxlines=emailmaxlines,2585**kw2586)258725882589classFQDNEnvironmentMixin(Environment):2590"""A mixin that sets the host's FQDN to its constructor argument."""25912592def__init__(self, fqdn, **kw):2593super(FQDNEnvironmentMixin, self).__init__(**kw)2594 self.COMPUTED_KEYS += ['fqdn']2595 self.__fqdn = fqdn25962597defget_fqdn(self):2598"""Return the fully-qualified domain name for this host.25992600 Return None if it is unavailable or unwanted."""26012602return self.__fqdn260326042605classConfigFQDNEnvironmentMixin(2606 ConfigEnvironmentMixin,2607 FQDNEnvironmentMixin,2608):2609"""Read the FQDN from the config."""26102611def__init__(self, config, **kw):2612 fqdn = config.get('fqdn')2613super(ConfigFQDNEnvironmentMixin, self).__init__(2614 config=config,2615 fqdn=fqdn,2616**kw2617)261826192620classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):2621"""Get the FQDN by calling socket.getfqdn()."""26222623def__init__(self, **kw):2624super(ComputeFQDNEnvironmentMixin, self).__init__(2625 fqdn=socket.getfqdn(),2626**kw2627)262826292630classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):2631"""Deduce pusher_email from pusher by appending an emaildomain."""26322633def__init__(self, **kw):2634super(PusherDomainEnvironmentMixin, self).__init__(**kw)2635 self.__emaildomain = self.config.get('emaildomain')26362637defget_pusher_email(self):2638if self.__emaildomain:2639# Derive the pusher's full email address in the default way:2640return'%s@%s'% (self.get_pusher(), self.__emaildomain)2641else:2642returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()264326442645classStaticRecipientsEnvironmentMixin(Environment):2646"""Set recipients statically based on constructor parameters."""26472648def__init__(2649 self,2650 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,2651**kw2652):2653super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)26542655# The recipients for various types of notification emails, as2656# RFC 2822 email addresses separated by commas (or the empty2657# string if no recipients are configured). Although there is2658# a mechanism to choose the recipient lists based on on the2659# actual *contents* of the change being reported, we only2660# choose based on the *type* of the change. Therefore we can2661# compute them once and for all:2662if not(refchange_recipients or2663 announce_recipients or2664 revision_recipients or2665 scancommitforcc):2666raiseConfigurationException('No email recipients configured!')2667 self.__refchange_recipients = refchange_recipients2668 self.__announce_recipients = announce_recipients2669 self.__revision_recipients = revision_recipients26702671defget_refchange_recipients(self, refchange):2672return self.__refchange_recipients26732674defget_announce_recipients(self, annotated_tag_change):2675return self.__announce_recipients26762677defget_revision_recipients(self, revision):2678return self.__revision_recipients267926802681classConfigRecipientsEnvironmentMixin(2682 ConfigEnvironmentMixin,2683 StaticRecipientsEnvironmentMixin2684):2685"""Determine recipients statically based on config."""26862687def__init__(self, config, **kw):2688super(ConfigRecipientsEnvironmentMixin, self).__init__(2689 config=config,2690 refchange_recipients=self._get_recipients(2691 config,'refchangelist','mailinglist',2692),2693 announce_recipients=self._get_recipients(2694 config,'announcelist','refchangelist','mailinglist',2695),2696 revision_recipients=self._get_recipients(2697 config,'commitlist','mailinglist',2698),2699 scancommitforcc=config.get('scancommitforcc'),2700**kw2701)27022703def_get_recipients(self, config, *names):2704"""Return the recipients for a particular type of message.27052706 Return the list of email addresses to which a particular type2707 of notification email should be sent, by looking at the config2708 value for "multimailhook.$name" for each of names. Use the2709 value from the first name that is configured. The return2710 value is a (possibly empty) string containing RFC 2822 email2711 addresses separated by commas. If no configuration could be2712 found, raise a ConfigurationException."""27132714for name in names:2715 lines = config.get_all(name)2716if lines is not None:2717 lines = [line.strip()for line in lines]2718# Single "none" is a special value equivalen to empty string.2719if lines == ['none']:2720 lines = ['']2721return', '.join(lines)2722else:2723return''272427252726classStaticRefFilterEnvironmentMixin(Environment):2727"""Set branch filter statically based on constructor parameters."""27282729def__init__(self, ref_filter_incl_regex, ref_filter_excl_regex,2730 ref_filter_do_send_regex, ref_filter_dont_send_regex,2731**kw):2732super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)27332734if ref_filter_incl_regex and ref_filter_excl_regex:2735raiseConfigurationException(2736"Cannot specify both a ref inclusion and exclusion regex.")2737 self.__is_inclusion_filter =bool(ref_filter_incl_regex)2738 default_exclude = self.get_default_ref_ignore_regex()2739if ref_filter_incl_regex:2740 ref_filter_regex = ref_filter_incl_regex2741elif ref_filter_excl_regex:2742 ref_filter_regex = ref_filter_excl_regex +'|'+ default_exclude2743else:2744 ref_filter_regex = default_exclude2745try:2746 self.__compiled_regex = re.compile(ref_filter_regex)2747exceptException:2748raiseConfigurationException(2749'Invalid Ref Filter Regex "%s":%s'% (ref_filter_regex, sys.exc_info()[1]))27502751if ref_filter_do_send_regex and ref_filter_dont_send_regex:2752raiseConfigurationException(2753"Cannot specify both a ref doSend and dontSend regex.")2754if ref_filter_do_send_regex or ref_filter_dont_send_regex:2755 self.__is_do_send_filter =bool(ref_filter_do_send_regex)2756if ref_filter_incl_regex:2757 ref_filter_send_regex = ref_filter_incl_regex2758elif ref_filter_excl_regex:2759 ref_filter_send_regex = ref_filter_excl_regex2760else:2761 ref_filter_send_regex ='.*'2762 self.__is_do_send_filter =True2763try:2764 self.__send_compiled_regex = re.compile(ref_filter_send_regex)2765exceptException:2766raiseConfigurationException(2767'Invalid Ref Filter Regex "%s":%s'%2768(ref_filter_send_regex, sys.exc_info()[1]))2769else:2770 self.__send_compiled_regex = self.__compiled_regex2771 self.__is_do_send_filter = self.__is_inclusion_filter27722773defget_ref_filter_regex(self, send_filter=False):2774if send_filter:2775return self.__send_compiled_regex, self.__is_do_send_filter2776else:2777return self.__compiled_regex, self.__is_inclusion_filter277827792780classConfigRefFilterEnvironmentMixin(2781 ConfigEnvironmentMixin,2782 StaticRefFilterEnvironmentMixin2783):2784"""Determine branch filtering statically based on config."""27852786def_get_regex(self, config, key):2787"""Get a list of whitespace-separated regex. The refFilter* config2788 variables are multivalued (hence the use of get_all), and we2789 allow each entry to be a whitespace-separated list (hence the2790 split on each line). The whole thing is glued into a single regex."""2791 values = config.get_all(key)2792if values is None:2793return values2794 items = []2795for line in values:2796for i in line.split():2797 items.append(i)2798if items == []:2799return None2800return'|'.join(items)28012802def__init__(self, config, **kw):2803super(ConfigRefFilterEnvironmentMixin, self).__init__(2804 config=config,2805 ref_filter_incl_regex=self._get_regex(config,'refFilterInclusionRegex'),2806 ref_filter_excl_regex=self._get_regex(config,'refFilterExclusionRegex'),2807 ref_filter_do_send_regex=self._get_regex(config,'refFilterDoSendRegex'),2808 ref_filter_dont_send_regex=self._get_regex(config,'refFilterDontSendRegex'),2809**kw2810)281128122813classProjectdescEnvironmentMixin(Environment):2814"""Make a "projectdesc" value available for templates.28152816 By default, it is set to the first line of $GIT_DIR/description2817 (if that file is present and appears to be set meaningfully)."""28182819def__init__(self, **kw):2820super(ProjectdescEnvironmentMixin, self).__init__(**kw)2821 self.COMPUTED_KEYS += ['projectdesc']28222823defget_projectdesc(self):2824"""Return a one-line descripition of the project."""28252826 git_dir =get_git_dir()2827try:2828 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()2829if projectdesc and not projectdesc.startswith('Unnamed repository'):2830return projectdesc2831exceptIOError:2832pass28332834return'UNNAMED PROJECT'283528362837classGenericEnvironmentMixin(Environment):2838defget_pusher(self):2839return self.osenv.get('USER', self.osenv.get('USERNAME','unknown user'))284028412842classGenericEnvironment(2843 ProjectdescEnvironmentMixin,2844 ConfigMaxlinesEnvironmentMixin,2845 ComputeFQDNEnvironmentMixin,2846 ConfigFilterLinesEnvironmentMixin,2847 ConfigRecipientsEnvironmentMixin,2848 ConfigRefFilterEnvironmentMixin,2849 PusherDomainEnvironmentMixin,2850 ConfigOptionsEnvironmentMixin,2851 GenericEnvironmentMixin,2852 Environment,2853):2854pass285528562857classGitoliteEnvironmentMixin(Environment):2858defget_repo_shortname(self):2859# The gitolite environment variable $GL_REPO is a pretty good2860# repo_shortname (though it's probably not as good as a value2861# the user might have explicitly put in his config).2862return(2863 self.osenv.get('GL_REPO',None)or2864super(GitoliteEnvironmentMixin, self).get_repo_shortname()2865)28662867defget_pusher(self):2868return self.osenv.get('GL_USER','unknown user')28692870defget_fromaddr(self, change=None):2871 GL_USER = self.osenv.get('GL_USER')2872if GL_USER is not None:2873# Find the path to gitolite.conf. Note that gitolite v32874# did away with the GL_ADMINDIR and GL_CONF environment2875# variables (they are now hard-coded).2876 GL_ADMINDIR = self.osenv.get(2877'GL_ADMINDIR',2878 os.path.expanduser(os.path.join('~','.gitolite')))2879 GL_CONF = self.osenv.get(2880'GL_CONF',2881 os.path.join(GL_ADMINDIR,'conf','gitolite.conf'))2882if os.path.isfile(GL_CONF):2883 f =open(GL_CONF,'rU')2884try:2885 in_user_emails_section =False2886 re_template = r'^\s*#\s*%s\s*$'2887 re_begin, re_user, re_end = (2888 re.compile(re_template % x)2889for x in(2890 r'BEGIN\s+USER\s+EMAILS',2891 re.escape(GL_USER) + r'\s+(.*)',2892 r'END\s+USER\s+EMAILS',2893))2894for l in f:2895 l = l.rstrip('\n')2896if not in_user_emails_section:2897if re_begin.match(l):2898 in_user_emails_section =True2899continue2900if re_end.match(l):2901break2902 m = re_user.match(l)2903if m:2904return m.group(1)2905finally:2906 f.close()2907returnsuper(GitoliteEnvironmentMixin, self).get_fromaddr(change)290829092910classIncrementalDateTime(object):2911"""Simple wrapper to give incremental date/times.29122913 Each call will result in a date/time a second later than the2914 previous call. This can be used to falsify email headers, to2915 increase the likelihood that email clients sort the emails2916 correctly."""29172918def__init__(self):2919 self.time = time.time()2920 self.next = self.__next__# Python 2 backward compatibility29212922def__next__(self):2923 formatted =formatdate(self.time,True)2924 self.time +=12925return formatted292629272928classGitoliteEnvironment(2929 ProjectdescEnvironmentMixin,2930 ConfigMaxlinesEnvironmentMixin,2931 ComputeFQDNEnvironmentMixin,2932 ConfigFilterLinesEnvironmentMixin,2933 ConfigRecipientsEnvironmentMixin,2934 ConfigRefFilterEnvironmentMixin,2935 PusherDomainEnvironmentMixin,2936 ConfigOptionsEnvironmentMixin,2937 GitoliteEnvironmentMixin,2938 Environment,2939):2940pass294129422943classStashEnvironmentMixin(Environment):2944def__init__(self, user=None, repo=None, **kw):2945super(StashEnvironmentMixin, self).__init__(**kw)2946 self.__user = user2947 self.__repo = repo29482949defget_repo_shortname(self):2950return self.__repo29512952defget_pusher(self):2953return re.match('(.*?)\s*<', self.__user).group(1)29542955defget_pusher_email(self):2956return self.__user29572958defget_fromaddr(self, change=None):2959return self.__user296029612962classStashEnvironment(2963 StashEnvironmentMixin,2964 ProjectdescEnvironmentMixin,2965 ConfigMaxlinesEnvironmentMixin,2966 ComputeFQDNEnvironmentMixin,2967 ConfigFilterLinesEnvironmentMixin,2968 ConfigRecipientsEnvironmentMixin,2969 ConfigRefFilterEnvironmentMixin,2970 PusherDomainEnvironmentMixin,2971 ConfigOptionsEnvironmentMixin,2972 Environment,2973):2974pass297529762977classGerritEnvironmentMixin(Environment):2978def__init__(self, project=None, submitter=None, update_method=None, **kw):2979super(GerritEnvironmentMixin, self).__init__(**kw)2980 self.__project = project2981 self.__submitter = submitter2982 self.__update_method = update_method2983"Make an 'update_method' value available for templates."2984 self.COMPUTED_KEYS += ['update_method']29852986defget_repo_shortname(self):2987return self.__project29882989defget_pusher(self):2990if self.__submitter:2991if self.__submitter.find('<') != -1:2992# Submitter has a configured email, we transformed2993# __submitter into an RFC 2822 string already.2994return re.match('(.*?)\s*<', self.__submitter).group(1)2995else:2996# Submitter has no configured email, it's just his name.2997return self.__submitter2998else:2999# If we arrive here, this means someone pushed "Submit" from3000# the gerrit web UI for the CR (or used one of the programmatic3001# APIs to do the same, such as gerrit review) and the3002# merge/push was done by the Gerrit user. It was technically3003# triggered by someone else, but sadly we have no way of3004# determining who that someone else is at this point.3005return'Gerrit'# 'unknown user'?30063007defget_pusher_email(self):3008if self.__submitter:3009return self.__submitter3010else:3011returnsuper(GerritEnvironmentMixin, self).get_pusher_email()30123013defget_fromaddr(self, change=None):3014if self.__submitter and self.__submitter.find('<') != -1:3015return self.__submitter3016else:3017returnsuper(GerritEnvironmentMixin, self).get_fromaddr(change)30183019defget_default_ref_ignore_regex(self):3020 default =super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()3021return default +'|^refs/changes/|^refs/cache-automerge/|^refs/meta/'30223023defget_revision_recipients(self, revision):3024# Merge commits created by Gerrit when users hit "Submit this patchset"3025# in the Web UI (or do equivalently with REST APIs or the gerrit review3026# command) are not something users want to see an individual email for.3027# Filter them out.3028 committer =read_git_output(['log','--no-walk','--format=%cN',3029 revision.rev.sha1])3030if committer =='Gerrit Code Review':3031return[]3032else:3033returnsuper(GerritEnvironmentMixin, self).get_revision_recipients(revision)30343035defget_update_method(self):3036return self.__update_method303730383039classGerritEnvironment(3040 GerritEnvironmentMixin,3041 ProjectdescEnvironmentMixin,3042 ConfigMaxlinesEnvironmentMixin,3043 ComputeFQDNEnvironmentMixin,3044 ConfigFilterLinesEnvironmentMixin,3045 ConfigRecipientsEnvironmentMixin,3046 ConfigRefFilterEnvironmentMixin,3047 PusherDomainEnvironmentMixin,3048 ConfigOptionsEnvironmentMixin,3049 Environment,3050):3051pass305230533054classPush(object):3055"""Represent an entire push (i.e., a group of ReferenceChanges).30563057 It is easy to figure out what commits were added to a *branch* by3058 a Reference change:30593060 git rev-list change.old..change.new30613062 or removed from a *branch*:30633064 git rev-list change.new..change.old30653066 But it is not quite so trivial to determine which entirely new3067 commits were added to the *repository* by a push and which old3068 commits were discarded by a push. A big part of the job of this3069 class is to figure out these things, and to make sure that new3070 commits are only detailed once even if they were added to multiple3071 references.30723073 The first step is to determine the "other" references--those3074 unaffected by the current push. They are computed by listing all3075 references then removing any affected by this push. The results3076 are stored in Push._other_ref_sha1s.30773078 The commits contained in the repository before this push were30793080 git rev-list other1 other2 other3 ... change1.old change2.old ...30813082 Where "changeN.old" is the old value of one of the references3083 affected by this push.30843085 The commits contained in the repository after this push are30863087 git rev-list other1 other2 other3 ... change1.new change2.new ...30883089 The commits added by this push are the difference between these3090 two sets, which can be written30913092 git rev-list \3093 ^other1 ^other2 ... \3094 ^change1.old ^change2.old ... \3095 change1.new change2.new ...30963097 The commits removed by this push can be computed by30983099 git rev-list \3100 ^other1 ^other2 ... \3101 ^change1.new ^change2.new ... \3102 change1.old change2.old ...31033104 The last point is that it is possible that other pushes are3105 occurring simultaneously to this one, so reference values can3106 change at any time. It is impossible to eliminate all race3107 conditions, but we reduce the window of time during which problems3108 can occur by translating reference names to SHA1s as soon as3109 possible and working with SHA1s thereafter (because SHA1s are3110 immutable)."""31113112# A map {(changeclass, changetype): integer} specifying the order3113# that reference changes will be processed if multiple reference3114# changes are included in a single push. The order is significant3115# mostly because new commit notifications are threaded together3116# with the first reference change that includes the commit. The3117# following order thus causes commits to be grouped with branch3118# changes (as opposed to tag changes) if possible.3119 SORT_ORDER =dict(3120(value, i)for(i, value)inenumerate([3121(BranchChange,'update'),3122(BranchChange,'create'),3123(AnnotatedTagChange,'update'),3124(AnnotatedTagChange,'create'),3125(NonAnnotatedTagChange,'update'),3126(NonAnnotatedTagChange,'create'),3127(BranchChange,'delete'),3128(AnnotatedTagChange,'delete'),3129(NonAnnotatedTagChange,'delete'),3130(OtherReferenceChange,'update'),3131(OtherReferenceChange,'create'),3132(OtherReferenceChange,'delete'),3133])3134)31353136def__init__(self, environment, changes, ignore_other_refs=False):3137 self.changes =sorted(changes, key=self._sort_key)3138 self.__other_ref_sha1s =None3139 self.__cached_commits_spec = {}3140 self.environment = environment31413142if ignore_other_refs:3143 self.__other_ref_sha1s =set()31443145@classmethod3146def_sort_key(klass, change):3147return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)31483149@property3150def_other_ref_sha1s(self):3151"""The GitObjects referred to by references unaffected by this push.3152 """3153if self.__other_ref_sha1s is None:3154# The refnames being changed by this push:3155 updated_refs =set(3156 change.refname3157for change in self.changes3158)31593160# The SHA-1s of commits referred to by all references in this3161# repository *except* updated_refs:3162 sha1s =set()3163 fmt = (3164'%(objectname) %(objecttype) %(refname)\n'3165'%(*objectname) %(*objecttype)%(refname)'3166)3167 ref_filter_regex, is_inclusion_filter = \3168 self.environment.get_ref_filter_regex()3169for line inread_git_lines(3170['for-each-ref','--format=%s'% (fmt,)]):3171(sha1,type, name) = line.split(' ',2)3172if(sha1 andtype=='commit'and3173 name not in updated_refs and3174include_ref(name, ref_filter_regex, is_inclusion_filter)):3175 sha1s.add(sha1)31763177 self.__other_ref_sha1s = sha1s31783179return self.__other_ref_sha1s31803181def_get_commits_spec_incl(self, new_or_old, reference_change=None):3182"""Get new or old SHA-1 from one or each of the changed refs.31833184 Return a list of SHA-1 commit identifier strings suitable as3185 arguments to 'git rev-list' (or 'git log' or ...). The3186 returned identifiers are either the old or new values from one3187 or all of the changed references, depending on the values of3188 new_or_old and reference_change.31893190 new_or_old is either the string 'new' or the string 'old'. If3191 'new', the returned SHA-1 identifiers are the new values from3192 each changed reference. If 'old', the SHA-1 identifiers are3193 the old values from each changed reference.31943195 If reference_change is specified and not None, only the new or3196 old reference from the specified reference is included in the3197 return value.31983199 This function returns None if there are no matching revisions3200 (e.g., because a branch was deleted and new_or_old is 'new').3201 """32023203if not reference_change:3204 incl_spec =sorted(3205getattr(change, new_or_old).sha13206for change in self.changes3207ifgetattr(change, new_or_old)3208)3209if not incl_spec:3210 incl_spec =None3211elif notgetattr(reference_change, new_or_old).commit_sha1:3212 incl_spec =None3213else:3214 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]3215return incl_spec32163217def_get_commits_spec_excl(self, new_or_old):3218"""Get exclusion revisions for determining new or discarded commits.32193220 Return a list of strings suitable as arguments to 'git3221 rev-list' (or 'git log' or ...) that will exclude all3222 commits that, depending on the value of new_or_old, were3223 either previously in the repository (useful for determining3224 which commits are new to the repository) or currently in the3225 repository (useful for determining which commits were3226 discarded from the repository).32273228 new_or_old is either the string 'new' or the string 'old'. If3229 'new', the commits to be excluded are those that were in the3230 repository before the push. If 'old', the commits to be3231 excluded are those that are currently in the repository. """32323233 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]3234 excl_revs = self._other_ref_sha1s.union(3235getattr(change, old_or_new).sha13236for change in self.changes3237ifgetattr(change, old_or_new).typein['commit','tag']3238)3239return['^'+ sha1 for sha1 insorted(excl_revs)]32403241defget_commits_spec(self, new_or_old, reference_change=None):3242"""Get rev-list arguments for added or discarded commits.32433244 Return a list of strings suitable as arguments to 'git3245 rev-list' (or 'git log' or ...) that select those commits3246 that, depending on the value of new_or_old, are either new to3247 the repository or were discarded from the repository.32483249 new_or_old is either the string 'new' or the string 'old'. If3250 'new', the returned list is used to select commits that are3251 new to the repository. If 'old', the returned value is used3252 to select the commits that have been discarded from the3253 repository.32543255 If reference_change is specified and not None, the new or3256 discarded commits are limited to those that are reachable from3257 the new or old value of the specified reference.32583259 This function returns None if there are no added (or discarded)3260 revisions.3261 """3262 key = (new_or_old, reference_change)3263if key not in self.__cached_commits_spec:3264 ret = self._get_commits_spec_incl(new_or_old, reference_change)3265if ret is not None:3266 ret.extend(self._get_commits_spec_excl(new_or_old))3267 self.__cached_commits_spec[key] = ret3268return self.__cached_commits_spec[key]32693270defget_new_commits(self, reference_change=None):3271"""Return a list of commits added by this push.32723273 Return a list of the object names of commits that were added3274 by the part of this push represented by reference_change. If3275 reference_change is None, then return a list of *all* commits3276 added by this push."""32773278 spec = self.get_commits_spec('new', reference_change)3279returngit_rev_list(spec)32803281defget_discarded_commits(self, reference_change):3282"""Return a list of commits discarded by this push.32833284 Return a list of the object names of commits that were3285 entirely discarded from the repository by the part of this3286 push represented by reference_change."""32873288 spec = self.get_commits_spec('old', reference_change)3289returngit_rev_list(spec)32903291defsend_emails(self, mailer, body_filter=None):3292"""Use send all of the notification emails needed for this push.32933294 Use send all of the notification emails (including reference3295 change emails and commit emails) needed for this push. Send3296 the emails using mailer. If body_filter is not None, then use3297 it to filter the lines that are intended for the email3298 body."""32993300# The sha1s of commits that were introduced by this push.3301# They will be removed from this set as they are processed, to3302# guarantee that one (and only one) email is generated for3303# each new commit.3304 unhandled_sha1s =set(self.get_new_commits())3305 send_date =IncrementalDateTime()3306for change in self.changes:3307 sha1s = []3308for sha1 inreversed(list(self.get_new_commits(change))):3309if sha1 in unhandled_sha1s:3310 sha1s.append(sha1)3311 unhandled_sha1s.remove(sha1)33123313# Check if we've got anyone to send to3314if not change.recipients:3315 change.environment.log_warning(3316'*** no recipients configured so no email will be sent\n'3317'*** for%rupdate%s->%s\n'3318% (change.refname, change.old.sha1, change.new.sha1,)3319)3320else:3321if not change.environment.quiet:3322 change.environment.log_msg(3323'Sending notification emails to:%s\n'% (change.recipients,))3324 extra_values = {'send_date': next(send_date)}33253326 rev = change.send_single_combined_email(sha1s)3327if rev:3328 mailer.send(3329 change.generate_combined_email(self, rev, body_filter, extra_values),3330 rev.recipients,3331)3332# This change is now fully handled; no need to handle3333# individual revisions any further.3334continue3335else:3336 mailer.send(3337 change.generate_email(self, body_filter, extra_values),3338 change.recipients,3339)33403341 max_emails = change.environment.maxcommitemails3342if max_emails andlen(sha1s) > max_emails:3343 change.environment.log_warning(3344'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s) +3345'*** Try setting multimailhook.maxCommitEmails to a greater value\n'+3346'*** Currently, multimailhook.maxCommitEmails=%d\n'% max_emails3347)3348return33493350for(num, sha1)inenumerate(sha1s):3351 rev =Revision(change,GitObject(sha1), num=num +1, tot=len(sha1s))3352if not rev.recipients and rev.cc_recipients:3353 change.environment.log_msg('*** Replacing Cc: with To:\n')3354 rev.recipients = rev.cc_recipients3355 rev.cc_recipients =None3356if rev.recipients:3357 extra_values = {'send_date': next(send_date)}3358 mailer.send(3359 rev.generate_email(self, body_filter, extra_values),3360 rev.recipients,3361)33623363# Consistency check:3364if unhandled_sha1s:3365 change.environment.log_error(3366'ERROR: No emails were sent for the following new commits:\n'3367'%s\n'3368% ('\n'.join(sorted(unhandled_sha1s)),)3369)337033713372definclude_ref(refname, ref_filter_regex, is_inclusion_filter):3373 does_match =bool(ref_filter_regex.search(refname))3374if is_inclusion_filter:3375return does_match3376else:# exclusion filter -- we include the ref if the regex doesn't match3377return not does_match337833793380defrun_as_post_receive_hook(environment, mailer):3381 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)3382 changes = []3383for line in sys.stdin:3384(oldrev, newrev, refname) = line.strip().split(' ',2)3385if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3386continue3387 changes.append(3388 ReferenceChange.create(environment, oldrev, newrev, refname)3389)3390if changes:3391 push =Push(environment, changes)3392 push.send_emails(mailer, body_filter=environment.filter_body)339333943395defrun_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):3396 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)3397if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3398return3399 changes = [3400 ReferenceChange.create(3401 environment,3402read_git_output(['rev-parse','--verify', oldrev]),3403read_git_output(['rev-parse','--verify', newrev]),3404 refname,3405),3406]3407 push =Push(environment, changes, force_send)3408 push.send_emails(mailer, body_filter=environment.filter_body)340934103411defchoose_mailer(config, environment):3412 mailer = config.get('mailer', default='sendmail')34133414if mailer =='smtp':3415 smtpserver = config.get('smtpserver', default='localhost')3416 smtpservertimeout =float(config.get('smtpservertimeout', default=10.0))3417 smtpserverdebuglevel =int(config.get('smtpserverdebuglevel', default=0))3418 smtpencryption = config.get('smtpencryption', default='none')3419 smtpuser = config.get('smtpuser', default='')3420 smtppass = config.get('smtppass', default='')3421 mailer =SMTPMailer(3422 envelopesender=(environment.get_sender()or environment.get_fromaddr()),3423 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,3424 smtpserverdebuglevel=smtpserverdebuglevel,3425 smtpencryption=smtpencryption,3426 smtpuser=smtpuser,3427 smtppass=smtppass,3428)3429elif mailer =='sendmail':3430 command = config.get('sendmailcommand')3431if command:3432 command = shlex.split(command)3433 mailer =SendMailer(command=command, envelopesender=environment.get_sender())3434else:3435 environment.log_error(3436'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer +3437'please use one of "smtp" or "sendmail".\n'3438)3439 sys.exit(1)3440return mailer344134423443KNOWN_ENVIRONMENTS = {3444'generic': GenericEnvironmentMixin,3445'gitolite': GitoliteEnvironmentMixin,3446'stash': StashEnvironmentMixin,3447'gerrit': GerritEnvironmentMixin,3448}344934503451defchoose_environment(config, osenv=None, env=None, recipients=None,3452 hook_info=None):3453if not osenv:3454 osenv = os.environ34553456 environment_mixins = [3457 ConfigRefFilterEnvironmentMixin,3458 ProjectdescEnvironmentMixin,3459 ConfigMaxlinesEnvironmentMixin,3460 ComputeFQDNEnvironmentMixin,3461 ConfigFilterLinesEnvironmentMixin,3462 PusherDomainEnvironmentMixin,3463 ConfigOptionsEnvironmentMixin,3464]3465 environment_kw = {3466'osenv': osenv,3467'config': config,3468}34693470if not env:3471 env = config.get('environment')34723473if not env:3474if'GL_USER'in osenv and'GL_REPO'in osenv:3475 env ='gitolite'3476else:3477 env ='generic'34783479 environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])34803481if env =='stash':3482 environment_kw['user'] = hook_info['stash_user']3483 environment_kw['repo'] = hook_info['stash_repo']3484elif env =='gerrit':3485 environment_kw['project'] = hook_info['project']3486 environment_kw['submitter'] = hook_info['submitter']3487 environment_kw['update_method'] = hook_info['update_method']34883489if recipients:3490 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)3491 environment_kw['refchange_recipients'] = recipients3492 environment_kw['announce_recipients'] = recipients3493 environment_kw['revision_recipients'] = recipients3494 environment_kw['scancommitforcc'] = config.get('scancommitforcc')3495else:3496 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)34973498 environment_klass =type(3499'EffectiveEnvironment',3500tuple(environment_mixins) + (Environment,),3501{},3502)3503returnenvironment_klass(**environment_kw)350435053506defget_version():3507 oldcwd = os.getcwd()3508try:3509try:3510 os.chdir(os.path.dirname(os.path.realpath(__file__)))3511 git_version =read_git_output(['describe','--tags','HEAD'])3512if git_version == __version__:3513return git_version3514else:3515return'%s(%s)'% (__version__, git_version)3516except:3517pass3518finally:3519 os.chdir(oldcwd)3520return __version__352135223523defcompute_gerrit_options(options, args, required_gerrit_options):3524if None in required_gerrit_options:3525raiseSystemExit("Error: Specify all of --oldrev, --newrev, --refname, "3526"and --project; or none of them.")35273528if options.environment not in(None,'gerrit'):3529raiseSystemExit("Non-gerrit environments incompatible with --oldrev, "3530"--newrev, --refname, and --project")3531 options.environment ='gerrit'35323533if args:3534raiseSystemExit("Error: Positional parameters not allowed with "3535"--oldrev, --newrev, and --refname.")35363537# Gerrit oddly omits 'refs/heads/' in the refname when calling3538# ref-updated hook; put it back.3539 git_dir =get_git_dir()3540if(not os.path.exists(os.path.join(git_dir, options.refname))and3541 os.path.exists(os.path.join(git_dir,'refs','heads',3542 options.refname))):3543 options.refname ='refs/heads/'+ options.refname35443545# Convert each string option unicode for Python3.3546if PYTHON3:3547 opts = ['environment','recipients','oldrev','newrev','refname',3548'project','submitter','stash-user','stash-repo']3549for opt in opts:3550if nothasattr(options, opt):3551continue3552 obj =getattr(options, opt)3553if obj:3554 enc = obj.encode('utf-8','surrogateescape')3555 dec = enc.decode('utf-8','replace')3556setattr(options, opt, dec)35573558# New revisions can appear in a gerrit repository either due to someone3559# pushing directly (in which case options.submitter will be set), or they3560# can press "Submit this patchset" in the web UI for some CR (in which3561# case options.submitter will not be set and gerrit will not have provided3562# us the information about who pressed the button).3563#3564# Note for the nit-picky: I'm lumping in REST API calls and the ssh3565# gerrit review command in with "Submit this patchset" button, since they3566# have the same effect.3567if options.submitter:3568 update_method ='pushed'3569# The submitter argument is almost an RFC 2822 email address; change it3570# from 'User Name (email@domain)' to 'User Name <email@domain>' so it is3571 options.submitter = options.submitter.replace('(','<').replace(')','>')3572else:3573 update_method ='submitted'3574# Gerrit knew who submitted this patchset, but threw that information3575# away when it invoked this hook. However, *IF* Gerrit created a3576# merge to bring the patchset in (project 'Submit Type' is either3577# "Always Merge", or is "Merge if Necessary" and happens to be3578# necessary for this particular CR), then it will have the committer3579# of that merge be 'Gerrit Code Review' and the author will be the3580# person who requested the submission of the CR. Since this is fairly3581# likely for most gerrit installations (of a reasonable size), it's3582# worth the extra effort to try to determine the actual submitter.3583 rev_info =read_git_lines(['log','--no-walk','--merges',3584'--format=%cN%n%aN <%aE>', options.newrev])3585if rev_info and rev_info[0] =='Gerrit Code Review':3586 options.submitter = rev_info[1]35873588# We pass back refname, oldrev, newrev as args because then the3589# gerrit ref-updated hook is much like the git update hook3590return(options,3591[options.refname, options.oldrev, options.newrev],3592{'project': options.project,'submitter': options.submitter,3593'update_method': update_method})359435953596defcheck_hook_specific_args(options, args):3597# First check for stash arguments3598if(options.stash_user is None) != (options.stash_repo is None):3599raiseSystemExit("Error: Specify both of --stash-user and "3600"--stash-repo or neither.")3601if options.stash_user:3602 options.environment ='stash'3603return options, args, {'stash_user': options.stash_user,3604'stash_repo': options.stash_repo}36053606# Finally, check for gerrit specific arguments3607 required_gerrit_options = (options.oldrev, options.newrev, options.refname,3608 options.project)3609if required_gerrit_options != (None,) *4:3610returncompute_gerrit_options(options, args, required_gerrit_options)36113612# No special options in use, just return what we started with3613return options, args, {}361436153616defmain(args):3617 parser = optparse.OptionParser(3618 description=__doc__,3619 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',3620)36213622 parser.add_option(3623'--environment','--env', action='store',type='choice',3624 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,3625help=(3626'Choose type of environment is in use. Default is taken from '3627'multimailhook.environment if set; otherwise "generic".'3628),3629)3630 parser.add_option(3631'--stdout', action='store_true', default=False,3632help='Output emails to stdout rather than sending them.',3633)3634 parser.add_option(3635'--recipients', action='store', default=None,3636help='Set list of email recipients for all types of emails.',3637)3638 parser.add_option(3639'--show-env', action='store_true', default=False,3640help=(3641'Write to stderr the values determined for the environment '3642'(intended for debugging purposes).'3643),3644)3645 parser.add_option(3646'--force-send', action='store_true', default=False,3647help=(3648'Force sending refchange email when using as an update hook. '3649'This is useful to work around the unreliable new commits '3650'detection in this mode.'3651),3652)3653 parser.add_option(3654'-c', metavar="<name>=<value>", action='append',3655help=(3656'Pass a configuration parameter through to git. The value given '3657'will override values from configuration files. See the -c option '3658'of git(1) for more details. (Only works with git >= 1.7.3)'3659),3660)3661 parser.add_option(3662'--version','-v', action='store_true', default=False,3663help=(3664"Display git-multimail's version"3665),3666)3667# The following options permit this script to be run as a gerrit3668# ref-updated hook. See e.g.3669# code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt3670# We suppress help for these items, since these are specific to gerrit,3671# and we don't want users directly using them any way other than how the3672# gerrit ref-updated hook is called.3673 parser.add_option('--oldrev', action='store',help=optparse.SUPPRESS_HELP)3674 parser.add_option('--newrev', action='store',help=optparse.SUPPRESS_HELP)3675 parser.add_option('--refname', action='store',help=optparse.SUPPRESS_HELP)3676 parser.add_option('--project', action='store',help=optparse.SUPPRESS_HELP)3677 parser.add_option('--submitter', action='store',help=optparse.SUPPRESS_HELP)36783679# The following allow this to be run as a stash asynchronous post-receive3680# hook (almost identical to a git post-receive hook but triggered also for3681# merges of pull requests from the UI). We suppress help for these items,3682# since these are specific to stash.3683 parser.add_option('--stash-user', action='store',help=optparse.SUPPRESS_HELP)3684 parser.add_option('--stash-repo', action='store',help=optparse.SUPPRESS_HELP)36853686(options, args) = parser.parse_args(args)3687(options, args, hook_info) =check_hook_specific_args(options, args)36883689if options.version:3690 sys.stdout.write('git-multimail version '+get_version() +'\n')3691return36923693if options.c:3694 parameters = os.environ.get('GIT_CONFIG_PARAMETERS','')3695if parameters:3696 parameters +=' '3697# git expects GIT_CONFIG_PARAMETERS to be of the form3698# "'name1=value1' 'name2=value2' 'name3=value3'"3699# including everything inside the double quotes (but not the double3700# quotes themselves). Spacing is critical. Also, if a value contains3701# a literal single quote that quote must be represented using the3702# four character sequence: '\''3703 parameters +=' '.join("'"+ x.replace("'","'\\''") +"'"for x in options.c)3704 os.environ['GIT_CONFIG_PARAMETERS'] = parameters37053706 config =Config('multimailhook')37073708try:3709 environment =choose_environment(3710 config, osenv=os.environ,3711 env=options.environment,3712 recipients=options.recipients,3713 hook_info=hook_info,3714)37153716if options.show_env:3717 sys.stderr.write('Environment values:\n')3718for(k, v)insorted(environment.get_values().items()):3719 sys.stderr.write('%s:%r\n'% (k, v))3720 sys.stderr.write('\n')37213722if options.stdout or environment.stdout:3723 mailer =OutputMailer(sys.stdout)3724else:3725 mailer =choose_mailer(config, environment)37263727# Dual mode: if arguments were specified on the command line, run3728# like an update hook; otherwise, run as a post-receive hook.3729if args:3730iflen(args) !=3:3731 parser.error('Need zero or three non-option arguments')3732(refname, oldrev, newrev) = args3733run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)3734else:3735run_as_post_receive_hook(environment, mailer)3736except ConfigurationException:3737 sys.exit(sys.exc_info()[1])3738exceptException:3739 t, e, tb = sys.exc_info()3740import traceback3741 sys.stdout.write('\n')3742 sys.stdout.write('Exception\''+ t.__name__+3743'\'raised. Please report this as a bug to\n')3744 sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')3745 sys.stdout.write('with the information below:\n\n')3746 sys.stdout.write('git-multimail version '+get_version() +'\n')3747 sys.stdout.write('Python version '+ sys.version +'\n')3748 traceback.print_exc(file=sys.stdout)3749 sys.exit(1)37503751if __name__ =='__main__':3752main(sys.argv[1:])