1#! /usr/bin/env python 2 3__version__ ='1.5.0' 4 5# Copyright (c) 2015-2016 Matthieu Moy and others 6# Copyright (c) 2012-2014 Michael Haggerty and others 7# Derived from contrib/hooks/post-receive-email, which is 8# Copyright (c) 2007 Andy Parkins 9# and also includes contributions by other authors. 10# 11# This file is part of git-multimail. 12# 13# git-multimail is free software: you can redistribute it and/or 14# modify it under the terms of the GNU General Public License version 15# 2 as published by the Free Software Foundation. 16# 17# This program is distributed in the hope that it will be useful, but 18# WITHOUT ANY WARRANTY; without even the implied warranty of 19# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 20# General Public License for more details. 21# 22# You should have received a copy of the GNU General Public License 23# along with this program. If not, see 24# <http://www.gnu.org/licenses/>. 25 26"""Generate notification emails for pushes to a git repository. 27 28This hook sends emails describing changes introduced by pushes to a 29git repository. For each reference that was changed, it emits one 30ReferenceChange email summarizing how the reference was changed, 31followed by one Revision email for each new commit that was introduced 32by the reference change. 33 34Each commit is announced in exactly one Revision email. If the same 35commit is merged into another branch in the same or a later push, then 36the ReferenceChange email will list the commit's SHA1 and its one-line 37summary, but no new Revision email will be generated. 38 39This script is designed to be used as a "post-receive" hook in a git 40repository (see githooks(5)). It can also be used as an "update" 41script, but this usage is not completely reliable and is deprecated. 42 43To help with debugging, this script accepts a --stdout option, which 44causes the emails to be written to standard output rather than sent 45using sendmail. 46 47See the accompanying README file for the complete documentation. 48 49""" 50 51import sys 52import os 53import re 54import bisect 55import socket 56import subprocess 57import shlex 58import optparse 59import logging 60import smtplib 61try: 62import ssl 63exceptImportError: 64# Python < 2.6 do not have ssl, but that's OK if we don't use it. 65pass 66import time 67 68import uuid 69import base64 70 71PYTHON3 = sys.version_info >= (3,0) 72 73if sys.version_info <= (2,5): 74defall(iterable): 75for element in iterable: 76if not element: 77return False 78return True 79 80 81defis_ascii(s): 82returnall(ord(c) <128andord(c) >0for c in s) 83 84 85if PYTHON3: 86defis_string(s): 87returnisinstance(s,str) 88 89defstr_to_bytes(s): 90return s.encode(ENCODING) 91 92defbytes_to_str(s, errors='strict'): 93return s.decode(ENCODING, errors) 94 95unicode=str 96 97defwrite_str(f, msg): 98# Try outputing with the default encoding. If it fails, 99# try UTF-8. 100try: 101 f.buffer.write(msg.encode(sys.getdefaultencoding())) 102exceptUnicodeEncodeError: 103 f.buffer.write(msg.encode(ENCODING)) 104 105defread_line(f): 106# Try reading with the default encoding. If it fails, 107# try UTF-8. 108 out = f.buffer.readline() 109try: 110return out.decode(sys.getdefaultencoding()) 111exceptUnicodeEncodeError: 112return out.decode(ENCODING) 113 114import html 115 116defhtml_escape(s): 117return html.escape(s) 118 119else: 120defis_string(s): 121try: 122returnisinstance(s, basestring) 123exceptNameError:# Silence Pyflakes warning 124raise 125 126defstr_to_bytes(s): 127return s 128 129defbytes_to_str(s, errors='strict'): 130return s 131 132defwrite_str(f, msg): 133 f.write(msg) 134 135defread_line(f): 136return f.readline() 137 138defnext(it): 139return it.next() 140 141import cgi 142 143defhtml_escape(s): 144return cgi.escape(s,True) 145 146try: 147from email.charset import Charset 148from email.utils import make_msgid 149from email.utils import getaddresses 150from email.utils import formataddr 151from email.utils import formatdate 152from email.header import Header 153exceptImportError: 154# Prior to Python 2.5, the email module used different names: 155from email.Charset import Charset 156from email.Utils import make_msgid 157from email.Utils import getaddresses 158from email.Utils import formataddr 159from email.Utils import formatdate 160from email.Header import Header 161 162 163DEBUG =False 164 165ZEROS ='0'*40 166LOGBEGIN ='- Log -----------------------------------------------------------------\n' 167LOGEND ='-----------------------------------------------------------------------\n' 168 169ADDR_HEADERS =set(['from','to','cc','bcc','reply-to','sender']) 170 171# It is assumed in many places that the encoding is uniformly UTF-8, 172# so changing these constants is unsupported. But define them here 173# anyway, to make it easier to find (at least most of) the places 174# where the encoding is important. 175(ENCODING, CHARSET) = ('UTF-8','utf-8') 176 177 178REF_CREATED_SUBJECT_TEMPLATE = ( 179'%(emailprefix)s%(refname_type)s %(short_refname)screated' 180' (now%(newrev_short)s)' 181) 182REF_UPDATED_SUBJECT_TEMPLATE = ( 183'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 184' (%(oldrev_short)s->%(newrev_short)s)' 185) 186REF_DELETED_SUBJECT_TEMPLATE = ( 187'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 188' (was%(oldrev_short)s)' 189) 190 191COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( 192'%(emailprefix)s%(refname_type)s %(short_refname)supdated:%(oneline)s' 193) 194 195REFCHANGE_HEADER_TEMPLATE ="""\ 196Date:%(send_date)s 197To:%(recipients)s 198Subject:%(subject)s 199MIME-Version: 1.0 200Content-Type: text/%(contenttype)s; charset=%(charset)s 201Content-Transfer-Encoding: 8bit 202Message-ID:%(msgid)s 203From:%(fromaddr)s 204Reply-To:%(reply_to)s 205Thread-Index:%(thread_index)s 206X-Git-Host:%(fqdn)s 207X-Git-Repo:%(repo_shortname)s 208X-Git-Refname:%(refname)s 209X-Git-Reftype:%(refname_type)s 210X-Git-Oldrev:%(oldrev)s 211X-Git-Newrev:%(newrev)s 212X-Git-NotificationType: ref_changed 213X-Git-Multimail-Version:%(multimail_version)s 214Auto-Submitted: auto-generated 215""" 216 217REFCHANGE_INTRO_TEMPLATE ="""\ 218This is an automated email from the git hooks/post-receive script. 219 220%(pusher)spushed a change to%(refname_type)s %(short_refname)s 221in repository%(repo_shortname)s. 222 223""" 224 225 226FOOTER_TEMPLATE ="""\ 227 228--\n\ 229To stop receiving notification emails like this one, please contact 230%(administrator)s. 231""" 232 233 234REWIND_ONLY_TEMPLATE ="""\ 235This update removed existing revisions from the reference, leaving the 236reference pointing at a previous point in the repository history. 237 238 * -- * -- N%(refname)s(%(newrev_short)s) 239\\ 240 O -- O -- O (%(oldrev_short)s) 241 242Any revisions marked "omit" are not gone; other references still 243refer to them. Any revisions marked "discard" are gone forever. 244""" 245 246 247NON_FF_TEMPLATE ="""\ 248This update added new revisions after undoing existing revisions. 249That is to say, some revisions that were in the old version of the 250%(refname_type)sare not in the new version. This situation occurs 251when a user --force pushes a change and generates a repository 252containing something like this: 253 254 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 255\\ 256 N -- N -- N%(refname)s(%(newrev_short)s) 257 258You should already have received notification emails for all of the O 259revisions, and so the following emails describe only the N revisions 260from the common base, B. 261 262Any revisions marked "omit" are not gone; other references still 263refer to them. Any revisions marked "discard" are gone forever. 264""" 265 266 267NO_NEW_REVISIONS_TEMPLATE ="""\ 268No new revisions were added by this update. 269""" 270 271 272DISCARDED_REVISIONS_TEMPLATE ="""\ 273This change permanently discards the following revisions: 274""" 275 276 277NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 278The revisions that were on this%(refname_type)sare still contained in 279other references; therefore, this change does not discard any commits 280from the repository. 281""" 282 283 284NEW_REVISIONS_TEMPLATE ="""\ 285The%(tot)srevisions listed above as "new" are entirely new to this 286repository and will be described in separate emails. The revisions 287listed as "add" were already present in the repository and have only 288been added to this reference. 289 290""" 291 292 293TAG_CREATED_TEMPLATE ="""\ 294 at%(newrev_short)-8s (%(newrev_type)s) 295""" 296 297 298TAG_UPDATED_TEMPLATE ="""\ 299*** WARNING: tag%(short_refname)swas modified! *** 300 301 from%(oldrev_short)-8s (%(oldrev_type)s) 302 to%(newrev_short)-8s (%(newrev_type)s) 303""" 304 305 306TAG_DELETED_TEMPLATE ="""\ 307*** WARNING: tag%(short_refname)swas deleted! *** 308 309""" 310 311 312# The template used in summary tables. It looks best if this uses the 313# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 314BRIEF_SUMMARY_TEMPLATE ="""\ 315%(action)8s%(rev_short)-8s%(text)s 316""" 317 318 319NON_COMMIT_UPDATE_TEMPLATE ="""\ 320This is an unusual reference change because the reference did not 321refer to a commit either before or after the change. We do not know 322how to provide full information about this reference change. 323""" 324 325 326REVISION_HEADER_TEMPLATE ="""\ 327Date:%(send_date)s 328To:%(recipients)s 329Cc:%(cc_recipients)s 330Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 331MIME-Version: 1.0 332Content-Type: text/%(contenttype)s; charset=%(charset)s 333Content-Transfer-Encoding: 8bit 334From:%(fromaddr)s 335Reply-To:%(reply_to)s 336In-Reply-To:%(reply_to_msgid)s 337References:%(reply_to_msgid)s 338Thread-Index:%(thread_index)s 339X-Git-Host:%(fqdn)s 340X-Git-Repo:%(repo_shortname)s 341X-Git-Refname:%(refname)s 342X-Git-Reftype:%(refname_type)s 343X-Git-Rev:%(rev)s 344X-Git-NotificationType: diff 345X-Git-Multimail-Version:%(multimail_version)s 346Auto-Submitted: auto-generated 347""" 348 349REVISION_INTRO_TEMPLATE ="""\ 350This is an automated email from the git hooks/post-receive script. 351 352%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 353in repository%(repo_shortname)s. 354 355""" 356 357LINK_TEXT_TEMPLATE ="""\ 358View the commit online: 359%(browse_url)s 360 361""" 362 363LINK_HTML_TEMPLATE ="""\ 364<p><a href="%(browse_url)s">View the commit online</a>.</p> 365""" 366 367 368REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 369 370 371# Combined, meaning refchange+revision email (for single-commit additions) 372COMBINED_HEADER_TEMPLATE ="""\ 373Date:%(send_date)s 374To:%(recipients)s 375Subject:%(subject)s 376MIME-Version: 1.0 377Content-Type: text/%(contenttype)s; charset=%(charset)s 378Content-Transfer-Encoding: 8bit 379Message-ID:%(msgid)s 380From:%(fromaddr)s 381Reply-To:%(reply_to)s 382X-Git-Host:%(fqdn)s 383X-Git-Repo:%(repo_shortname)s 384X-Git-Refname:%(refname)s 385X-Git-Reftype:%(refname_type)s 386X-Git-Oldrev:%(oldrev)s 387X-Git-Newrev:%(newrev)s 388X-Git-Rev:%(rev)s 389X-Git-NotificationType: ref_changed_plus_diff 390X-Git-Multimail-Version:%(multimail_version)s 391Auto-Submitted: auto-generated 392""" 393 394COMBINED_INTRO_TEMPLATE ="""\ 395This is an automated email from the git hooks/post-receive script. 396 397%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 398in repository%(repo_shortname)s. 399 400""" 401 402COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE 403 404 405classCommandError(Exception): 406def__init__(self, cmd, retcode): 407 self.cmd = cmd 408 self.retcode = retcode 409Exception.__init__( 410 self, 411'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 412) 413 414 415classConfigurationException(Exception): 416pass 417 418 419# The "git" program (this could be changed to include a full path): 420GIT_EXECUTABLE ='git' 421 422 423# How "git" should be invoked (including global arguments), as a list 424# of words. This variable is usually initialized automatically by 425# read_git_output() via choose_git_command(), but if a value is set 426# here then it will be used unconditionally. 427GIT_CMD =None 428 429 430defchoose_git_command(): 431"""Decide how to invoke git, and record the choice in GIT_CMD.""" 432 433global GIT_CMD 434 435if GIT_CMD is None: 436try: 437# Check to see whether the "-c" option is accepted (it was 438# only added in Git 1.7.2). We don't actually use the 439# output of "git --version", though if we needed more 440# specific version information this would be the place to 441# do it. 442 cmd = [GIT_EXECUTABLE,'-c','foo.bar=baz','--version'] 443read_output(cmd) 444 GIT_CMD = [GIT_EXECUTABLE,'-c','i18n.logoutputencoding=%s'% (ENCODING,)] 445except CommandError: 446 GIT_CMD = [GIT_EXECUTABLE] 447 448 449defread_git_output(args,input=None, keepends=False, **kw): 450"""Read the output of a Git command.""" 451 452if GIT_CMD is None: 453choose_git_command() 454 455returnread_output(GIT_CMD + args,input=input, keepends=keepends, **kw) 456 457 458defread_output(cmd,input=None, keepends=False, **kw): 459ifinput: 460 stdin = subprocess.PIPE 461input=str_to_bytes(input) 462else: 463 stdin =None 464 errors ='strict' 465if'errors'in kw: 466 errors = kw['errors'] 467del kw['errors'] 468 p = subprocess.Popen( 469tuple(str_to_bytes(w)for w in cmd), 470 stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 471) 472(out, err) = p.communicate(input) 473 out =bytes_to_str(out, errors=errors) 474 retcode = p.wait() 475if retcode: 476raiseCommandError(cmd, retcode) 477if not keepends: 478 out = out.rstrip('\n\r') 479return out 480 481 482defread_git_lines(args, keepends=False, **kw): 483"""Return the lines output by Git command. 484 485 Return as single lines, with newlines stripped off.""" 486 487returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 488 489 490defgit_rev_list_ish(cmd, spec, args=None, **kw): 491"""Common functionality for invoking a 'git rev-list'-like command. 492 493 Parameters: 494 * cmd is the Git command to run, e.g., 'rev-list' or 'log'. 495 * spec is a list of revision arguments to pass to the named 496 command. If None, this function returns an empty list. 497 * args is a list of extra arguments passed to the named command. 498 * All other keyword arguments (if any) are passed to the 499 underlying read_git_lines() function. 500 501 Return the output of the Git command in the form of a list, one 502 entry per output line. 503 """ 504if spec is None: 505return[] 506if args is None: 507 args = [] 508 args = [cmd,'--stdin'] + args 509 spec_stdin =''.join(s +'\n'for s in spec) 510returnread_git_lines(args,input=spec_stdin, **kw) 511 512 513defgit_rev_list(spec, **kw): 514"""Run 'git rev-list' with the given list of revision arguments. 515 516 See git_rev_list_ish() for parameter and return value 517 documentation. 518 """ 519returngit_rev_list_ish('rev-list', spec, **kw) 520 521 522defgit_log(spec, **kw): 523"""Run 'git log' with the given list of revision arguments. 524 525 See git_rev_list_ish() for parameter and return value 526 documentation. 527 """ 528returngit_rev_list_ish('log', spec, **kw) 529 530 531defheader_encode(text, header_name=None): 532"""Encode and line-wrap the value of an email header field.""" 533 534# Convert to unicode, if required. 535if notisinstance(text,unicode): 536 text =unicode(text,'utf-8') 537 538ifis_ascii(text): 539 charset ='ascii' 540else: 541 charset ='utf-8' 542 543returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 544 545 546defaddr_header_encode(text, header_name=None): 547"""Encode and line-wrap the value of an email header field containing 548 email addresses.""" 549 550# Convert to unicode, if required. 551if notisinstance(text,unicode): 552 text =unicode(text,'utf-8') 553 554 text =', '.join( 555formataddr((header_encode(name), emailaddr)) 556for name, emailaddr ingetaddresses([text]) 557) 558 559ifis_ascii(text): 560 charset ='ascii' 561else: 562 charset ='utf-8' 563 564returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 565 566 567classConfig(object): 568def__init__(self, section, git_config=None): 569"""Represent a section of the git configuration. 570 571 If git_config is specified, it is passed to "git config" in 572 the GIT_CONFIG environment variable, meaning that "git config" 573 will read the specified path rather than the Git default 574 config paths.""" 575 576 self.section = section 577if git_config: 578 self.env = os.environ.copy() 579 self.env['GIT_CONFIG'] = git_config 580else: 581 self.env =None 582 583@staticmethod 584def_split(s): 585"""Split NUL-terminated values.""" 586 587 words = s.split('\0') 588assert words[-1] =='' 589return words[:-1] 590 591@staticmethod 592defadd_config_parameters(c): 593"""Add configuration parameters to Git. 594 595 c is either an str or a list of str, each element being of the 596 form 'var=val' or 'var', with the same syntax and meaning as 597 the argument of 'git -c var=val'. 598 """ 599ifisinstance(c,str): 600 c = (c,) 601 parameters = os.environ.get('GIT_CONFIG_PARAMETERS','') 602if parameters: 603 parameters +=' ' 604# git expects GIT_CONFIG_PARAMETERS to be of the form 605# "'name1=value1' 'name2=value2' 'name3=value3'" 606# including everything inside the double quotes (but not the double 607# quotes themselves). Spacing is critical. Also, if a value contains 608# a literal single quote that quote must be represented using the 609# four character sequence: '\'' 610 parameters +=' '.join("'"+ x.replace("'","'\\''") +"'"for x in c) 611 os.environ['GIT_CONFIG_PARAMETERS'] = parameters 612 613defget(self, name, default=None): 614try: 615 values = self._split(read_git_output( 616['config','--get','--null','%s.%s'% (self.section, name)], 617 env=self.env, keepends=True, 618)) 619assertlen(values) ==1 620return values[0] 621except CommandError: 622return default 623 624defget_bool(self, name, default=None): 625try: 626 value =read_git_output( 627['config','--get','--bool','%s.%s'% (self.section, name)], 628 env=self.env, 629) 630except CommandError: 631return default 632return value =='true' 633 634defget_all(self, name, default=None): 635"""Read a (possibly multivalued) setting from the configuration. 636 637 Return the result as a list of values, or default if the name 638 is unset.""" 639 640try: 641return self._split(read_git_output( 642['config','--get-all','--null','%s.%s'% (self.section, name)], 643 env=self.env, keepends=True, 644)) 645except CommandError: 646 t, e, traceback = sys.exc_info() 647if e.retcode ==1: 648# "the section or key is invalid"; i.e., there is no 649# value for the specified key. 650return default 651else: 652raise 653 654defset(self, name, value): 655read_git_output( 656['config','%s.%s'% (self.section, name), value], 657 env=self.env, 658) 659 660defadd(self, name, value): 661read_git_output( 662['config','--add','%s.%s'% (self.section, name), value], 663 env=self.env, 664) 665 666def__contains__(self, name): 667return self.get_all(name, default=None)is not None 668 669# We don't use this method anymore internally, but keep it here in 670# case somebody is calling it from their own code: 671defhas_key(self, name): 672return name in self 673 674defunset_all(self, name): 675try: 676read_git_output( 677['config','--unset-all','%s.%s'% (self.section, name)], 678 env=self.env, 679) 680except CommandError: 681 t, e, traceback = sys.exc_info() 682if e.retcode ==5: 683# The name doesn't exist, which is what we wanted anyway... 684pass 685else: 686raise 687 688defset_recipients(self, name, value): 689 self.unset_all(name) 690for pair ingetaddresses([value]): 691 self.add(name,formataddr(pair)) 692 693 694defgenerate_summaries(*log_args): 695"""Generate a brief summary for each revision requested. 696 697 log_args are strings that will be passed directly to "git log" as 698 revision selectors. Iterate over (sha1_short, subject) for each 699 commit specified by log_args (subject is the first line of the 700 commit message as a string without EOLs).""" 701 702 cmd = [ 703'log','--abbrev','--format=%h%s', 704] +list(log_args) + ['--'] 705for line inread_git_lines(cmd): 706yieldtuple(line.split(' ',1)) 707 708 709deflimit_lines(lines, max_lines): 710for(index, line)inenumerate(lines): 711if index < max_lines: 712yield line 713 714if index >= max_lines: 715yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 716 717 718deflimit_linelength(lines, max_linelength): 719for line in lines: 720# Don't forget that lines always include a trailing newline. 721iflen(line) > max_linelength +1: 722 line = line[:max_linelength -7] +' [...]\n' 723yield line 724 725 726classCommitSet(object): 727"""A (constant) set of object names. 728 729 The set should be initialized with full SHA1 object names. The 730 __contains__() method returns True iff its argument is an 731 abbreviation of any the names in the set.""" 732 733def__init__(self, names): 734 self._names =sorted(names) 735 736def__len__(self): 737returnlen(self._names) 738 739def__contains__(self, sha1_abbrev): 740"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 741 742 i = bisect.bisect_left(self._names, sha1_abbrev) 743return i <len(self)and self._names[i].startswith(sha1_abbrev) 744 745 746classGitObject(object): 747def__init__(self, sha1,type=None): 748if sha1 == ZEROS: 749 self.sha1 = self.type= self.commit_sha1 =None 750else: 751 self.sha1 = sha1 752 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 753 754if self.type=='commit': 755 self.commit_sha1 = self.sha1 756elif self.type=='tag': 757try: 758 self.commit_sha1 =read_git_output( 759['rev-parse','--verify','%s^0'% (self.sha1,)] 760) 761except CommandError: 762# Cannot deref tag to determine commit_sha1 763 self.commit_sha1 =None 764else: 765 self.commit_sha1 =None 766 767 self.short =read_git_output(['rev-parse','--short', sha1]) 768 769defget_summary(self): 770"""Return (sha1_short, subject) for this commit.""" 771 772if not self.sha1: 773raiseValueError('Empty commit has no summary') 774 775returnnext(iter(generate_summaries('--no-walk', self.sha1))) 776 777def__eq__(self, other): 778returnisinstance(other, GitObject)and self.sha1 == other.sha1 779 780def__ne__(self, other): 781return not self == other 782 783def__hash__(self): 784returnhash(self.sha1) 785 786def__nonzero__(self): 787returnbool(self.sha1) 788 789def__bool__(self): 790"""Python 2 backward compatibility""" 791return self.__nonzero__() 792 793def__str__(self): 794return self.sha1 or ZEROS 795 796 797classChange(object): 798"""A Change that has been made to the Git repository. 799 800 Abstract class from which both Revisions and ReferenceChanges are 801 derived. A Change knows how to generate a notification email 802 describing itself.""" 803 804def__init__(self, environment): 805 self.environment = environment 806 self._values =None 807 self._contains_html_diff =False 808 809def_contains_diff(self): 810# We do contain a diff, should it be rendered in HTML? 811if self.environment.commit_email_format =="html": 812 self._contains_html_diff =True 813 814def_compute_values(self): 815"""Return a dictionary{keyword: expansion}for this Change. 816 817 Derived classes overload this method to add more entries to 818 the return value. This method is used internally by 819 get_values(). The return value should always be a new 820 dictionary.""" 821 822 values = self.environment.get_values() 823 fromaddr = self.environment.get_fromaddr(change=self) 824if fromaddr is not None: 825 values['fromaddr'] = fromaddr 826 values['multimail_version'] =get_version() 827return values 828 829# Aliases usable in template strings. Tuple of pairs (destination, 830# source). 831 VALUES_ALIAS = ( 832("id","newrev"), 833) 834 835defget_values(self, **extra_values): 836"""Return a dictionary{keyword: expansion}for this Change. 837 838 Return a dictionary mapping keywords to the values that they 839 should be expanded to for this Change (used when interpolating 840 template strings). If any keyword arguments are supplied, add 841 those to the return value as well. The return value is always 842 a new dictionary.""" 843 844if self._values is None: 845 self._values = self._compute_values() 846 847 values = self._values.copy() 848if extra_values: 849 values.update(extra_values) 850 851for alias, val in self.VALUES_ALIAS: 852 values[alias] = values[val] 853return values 854 855defexpand(self, template, **extra_values): 856"""Expand template. 857 858 Expand the template (which should be a string) using string 859 interpolation of the values for this Change. If any keyword 860 arguments are provided, also include those in the keywords 861 available for interpolation.""" 862 863return template % self.get_values(**extra_values) 864 865defexpand_lines(self, template, html_escape_val=False, **extra_values): 866"""Break template into lines and expand each line.""" 867 868 values = self.get_values(**extra_values) 869if html_escape_val: 870for k in values: 871ifis_string(values[k]): 872 values[k] =html_escape(values[k]) 873for line in template.splitlines(True): 874yield line % values 875 876defexpand_header_lines(self, template, **extra_values): 877"""Break template into lines and expand each line as an RFC 2822 header. 878 879 Encode values and split up lines that are too long. Silently 880 skip lines that contain references to unknown variables.""" 881 882 values = self.get_values(**extra_values) 883if self._contains_html_diff: 884 self._content_type ='html' 885else: 886 self._content_type ='plain' 887 values['contenttype'] = self._content_type 888 889for line in template.splitlines(): 890(name, value) = line.split(': ',1) 891 892try: 893 value = value % values 894exceptKeyError: 895 t, e, traceback = sys.exc_info() 896if DEBUG: 897 self.environment.log_warning( 898'Warning: unknown variable%rin the following line; line skipped:\n' 899'%s\n' 900% (e.args[0], line,) 901) 902else: 903if name.lower()in ADDR_HEADERS: 904 value =addr_header_encode(value, name) 905else: 906 value =header_encode(value, name) 907for splitline in('%s:%s\n'% (name, value)).splitlines(True): 908yield splitline 909 910defgenerate_email_header(self): 911"""Generate the RFC 2822 email headers for this Change, a line at a time. 912 913 The output should not include the trailing blank line.""" 914 915raiseNotImplementedError() 916 917defgenerate_browse_link(self, base_url): 918"""Generate a link to an online repository browser.""" 919returniter(()) 920 921defgenerate_email_intro(self, html_escape_val=False): 922"""Generate the email intro for this Change, a line at a time. 923 924 The output will be used as the standard boilerplate at the top 925 of the email body.""" 926 927raiseNotImplementedError() 928 929defgenerate_email_body(self, push): 930"""Generate the main part of the email body, a line at a time. 931 932 The text in the body might be truncated after a specified 933 number of lines (see multimailhook.emailmaxlines).""" 934 935raiseNotImplementedError() 936 937defgenerate_email_footer(self, html_escape_val): 938"""Generate the footer of the email, a line at a time. 939 940 The footer is always included, irrespective of 941 multimailhook.emailmaxlines.""" 942 943raiseNotImplementedError() 944 945def_wrap_for_html(self, lines): 946"""Wrap the lines in HTML <pre> tag when using HTML format. 947 948 Escape special HTML characters and add <pre> and </pre> tags around 949 the given lines if we should be generating HTML as indicated by 950 self._contains_html_diff being set to true. 951 """ 952if self._contains_html_diff: 953yield"<pre style='margin:0'>\n" 954 955for line in lines: 956yieldhtml_escape(line) 957 958yield'</pre>\n' 959else: 960for line in lines: 961yield line 962 963defgenerate_email(self, push, body_filter=None, extra_header_values={}): 964"""Generate an email describing this change. 965 966 Iterate over the lines (including the header lines) of an 967 email describing this change. If body_filter is not None, 968 then use it to filter the lines that are intended for the 969 email body. 970 971 The extra_header_values field is received as a dict and not as 972 **kwargs, to allow passing other keyword arguments in the 973 future (e.g. passing extra values to generate_email_intro()""" 974 975for line in self.generate_email_header(**extra_header_values): 976yield line 977yield'\n' 978 html_escape_val = (self.environment.html_in_intro and 979 self._contains_html_diff) 980 intro = self.generate_email_intro(html_escape_val) 981if not self.environment.html_in_intro: 982 intro = self._wrap_for_html(intro) 983for line in intro: 984yield line 985 986if self.environment.commitBrowseURL: 987for line in self.generate_browse_link(self.environment.commitBrowseURL): 988yield line 989 990 body = self.generate_email_body(push) 991if body_filter is not None: 992 body =body_filter(body) 993 994 diff_started =False 995if self._contains_html_diff: 996# "white-space: pre" is the default, but we need to 997# specify it again in case the message is viewed in a 998# webmail which wraps it in an element setting white-space 999# to something else (Zimbra does this and sets1000# white-space: pre-line).1001yield'<pre style="white-space: pre; background: #F8F8F8">'1002for line in body:1003if self._contains_html_diff:1004# This is very, very naive. It would be much better to really1005# parse the diff, i.e. look at how many lines do we have in1006# the hunk headers instead of blindly highlighting everything1007# that looks like it might be part of a diff.1008 bgcolor =''1009 fgcolor =''1010if line.startswith('--- a/'):1011 diff_started =True1012 bgcolor ='e0e0ff'1013elif line.startswith('diff ')or line.startswith('index '):1014 diff_started =True1015 fgcolor ='808080'1016elif diff_started:1017if line.startswith('+++ '):1018 bgcolor ='e0e0ff'1019elif line.startswith('@@'):1020 bgcolor ='e0e0e0'1021elif line.startswith('+'):1022 bgcolor ='e0ffe0'1023elif line.startswith('-'):1024 bgcolor ='ffe0e0'1025elif line.startswith('commit '):1026 fgcolor ='808000'1027elif line.startswith(' '):1028 fgcolor ='404040'10291030# Chop the trailing LF, we don't want it inside <pre>.1031 line =html_escape(line[:-1])10321033if bgcolor or fgcolor:1034 style ='display:block; white-space:pre;'1035if bgcolor:1036 style +='background:#'+ bgcolor +';'1037if fgcolor:1038 style +='color:#'+ fgcolor +';'1039# Use a <span style='display:block> to color the1040# whole line. The newline must be inside the span1041# to display properly both in Firefox and in1042# text-based browser.1043 line ="<span style='%s'>%s\n</span>"% (style, line)1044else:1045 line = line +'\n'10461047yield line1048if self._contains_html_diff:1049yield'</pre>'1050 html_escape_val = (self.environment.html_in_footer and1051 self._contains_html_diff)1052 footer = self.generate_email_footer(html_escape_val)1053if not self.environment.html_in_footer:1054 footer = self._wrap_for_html(footer)1055for line in footer:1056yield line10571058defget_specific_fromaddr(self):1059"""For kinds of Changes which specify it, return the kind-specific1060 From address to use."""1061return None106210631064classRevision(Change):1065"""A Change consisting of a single git commit."""10661067 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')10681069def__init__(self, reference_change, rev, num, tot):1070 Change.__init__(self, reference_change.environment)1071 self.reference_change = reference_change1072 self.rev = rev1073 self.change_type = self.reference_change.change_type1074 self.refname = self.reference_change.refname1075 self.num = num1076 self.tot = tot1077 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1])1078 self.recipients = self.environment.get_revision_recipients(self)10791080# -s is short for --no-patch, but -s works on older git's (e.g. 1.7)1081 self.parents =read_git_lines(['show','-s','--format=%P',1082 self.rev.sha1])[0].split()10831084 self.cc_recipients =''1085if self.environment.get_scancommitforcc():1086 self.cc_recipients =', '.join(to.strip()for to in self._cc_recipients())1087if self.cc_recipients:1088 self.environment.log_msg(1089'Add%sto CC for%s'% (self.cc_recipients, self.rev.sha1))10901091def_cc_recipients(self):1092 cc_recipients = []1093 message =read_git_output(['log','--no-walk','--format=%b', self.rev.sha1])1094 lines = message.strip().split('\n')1095for line in lines:1096 m = re.match(self.CC_RE, line)1097if m:1098 cc_recipients.append(m.group('to'))10991100return cc_recipients11011102def_compute_values(self):1103 values = Change._compute_values(self)11041105 oneline =read_git_output(1106['log','--format=%s','--no-walk', self.rev.sha1]1107)11081109 max_subject_length = self.environment.get_max_subject_length()1110if max_subject_length >0andlen(oneline) > max_subject_length:1111 oneline = oneline[:max_subject_length -6] +' [...]'11121113 values['rev'] = self.rev.sha11114 values['parents'] =' '.join(self.parents)1115 values['rev_short'] = self.rev.short1116 values['change_type'] = self.change_type1117 values['refname'] = self.refname1118 values['newrev'] = self.rev.sha11119 values['short_refname'] = self.reference_change.short_refname1120 values['refname_type'] = self.reference_change.refname_type1121 values['reply_to_msgid'] = self.reference_change.msgid1122 values['thread_index'] = self.reference_change.thread_index1123 values['num'] = self.num1124 values['tot'] = self.tot1125 values['recipients'] = self.recipients1126if self.cc_recipients:1127 values['cc_recipients'] = self.cc_recipients1128 values['oneline'] = oneline1129 values['author'] = self.author11301131 reply_to = self.environment.get_reply_to_commit(self)1132if reply_to:1133 values['reply_to'] = reply_to11341135return values11361137defgenerate_email_header(self, **extra_values):1138for line in self.expand_header_lines(1139 REVISION_HEADER_TEMPLATE, **extra_values1140):1141yield line11421143defgenerate_browse_link(self, base_url):1144if'%('not in base_url:1145 base_url +='%(id)s'1146 url ="".join(self.expand_lines(base_url))1147if self._content_type =='html':1148for line in self.expand_lines(LINK_HTML_TEMPLATE,1149 html_escape_val=True,1150 browse_url=url):1151yield line1152elif self._content_type =='plain':1153for line in self.expand_lines(LINK_TEXT_TEMPLATE,1154 html_escape_val=False,1155 browse_url=url):1156yield line1157else:1158raiseNotImplementedError("Content-type%sunsupported. Please report it as a bug.")11591160defgenerate_email_intro(self, html_escape_val=False):1161for line in self.expand_lines(REVISION_INTRO_TEMPLATE,1162 html_escape_val=html_escape_val):1163yield line11641165defgenerate_email_body(self, push):1166"""Show this revision."""11671168for line inread_git_lines(1169['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],1170 keepends=True,1171 errors='replace'):1172if line.startswith('Date: ')and self.environment.date_substitute:1173yield self.environment.date_substitute + line[len('Date: '):]1174else:1175yield line11761177defgenerate_email_footer(self, html_escape_val):1178return self.expand_lines(REVISION_FOOTER_TEMPLATE,1179 html_escape_val=html_escape_val)11801181defgenerate_email(self, push, body_filter=None, extra_header_values={}):1182 self._contains_diff()1183return Change.generate_email(self, push, body_filter, extra_header_values)11841185defget_specific_fromaddr(self):1186return self.environment.from_commit118711881189classReferenceChange(Change):1190"""A Change to a Git reference.11911192 An abstract class representing a create, update, or delete of a1193 Git reference. Derived classes handle specific types of reference1194 (e.g., tags vs. branches). These classes generate the main1195 reference change email summarizing the reference change and1196 whether it caused any any commits to be added or removed.11971198 ReferenceChange objects are usually created using the static1199 create() method, which has the logic to decide which derived class1200 to instantiate."""12011202 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')12031204@staticmethod1205defcreate(environment, oldrev, newrev, refname):1206"""Return a ReferenceChange object representing the change.12071208 Return an object that represents the type of change that is being1209 made. oldrev and newrev should be SHA1s or ZEROS."""12101211 old =GitObject(oldrev)1212 new =GitObject(newrev)1213 rev = new or old12141215# The revision type tells us what type the commit is, combined with1216# the location of the ref we can decide between1217# - working branch1218# - tracking branch1219# - unannotated tag1220# - annotated tag1221 m = ReferenceChange.REF_RE.match(refname)1222if m:1223 area = m.group('area')1224 short_refname = m.group('shortname')1225else:1226 area =''1227 short_refname = refname12281229if rev.type=='tag':1230# Annotated tag:1231 klass = AnnotatedTagChange1232elif rev.type=='commit':1233if area =='tags':1234# Non-annotated tag:1235 klass = NonAnnotatedTagChange1236elif area =='heads':1237# Branch:1238 klass = BranchChange1239elif area =='remotes':1240# Tracking branch:1241 environment.log_warning(1242'*** Push-update of tracking branch%r\n'1243'*** - incomplete email generated.'1244% (refname,)1245)1246 klass = OtherReferenceChange1247else:1248# Some other reference namespace:1249 environment.log_warning(1250'*** Push-update of strange reference%r\n'1251'*** - incomplete email generated.'1252% (refname,)1253)1254 klass = OtherReferenceChange1255else:1256# Anything else (is there anything else?)1257 environment.log_warning(1258'*** Unknown type of update to%r(%s)\n'1259'*** - incomplete email generated.'1260% (refname, rev.type,)1261)1262 klass = OtherReferenceChange12631264returnklass(1265 environment,1266 refname=refname, short_refname=short_refname,1267 old=old, new=new, rev=rev,1268)12691270@staticmethod1271defmake_thread_index():1272"""Return a string appropriate for the Thread-Index header,1273 needed by MS Outlook to get threading right.12741275 The format is (base64-encoded):1276 - 1 byte must be 11277 - 5 bytes encode a date (hardcoded here)1278 - 16 bytes for a globally unique identifier12791280 FIXME: Unfortunately, even with the Thread-Index field, MS1281 Outlook doesn't seem to do the threading reliably (see1282 https://github.com/git-multimail/git-multimail/pull/194).1283 """1284 thread_index = b'\x01\x00\x00\x12\x34\x56'+ uuid.uuid4().bytes1285return base64.standard_b64encode(thread_index).decode('ascii')12861287def__init__(self, environment, refname, short_refname, old, new, rev):1288 Change.__init__(self, environment)1289 self.change_type = {1290(False,True):'create',1291(True,True):'update',1292(True,False):'delete',1293}[bool(old),bool(new)]1294 self.refname = refname1295 self.short_refname = short_refname1296 self.old = old1297 self.new = new1298 self.rev = rev1299 self.msgid =make_msgid()1300 self.thread_index = self.make_thread_index()1301 self.diffopts = environment.diffopts1302 self.graphopts = environment.graphopts1303 self.logopts = environment.logopts1304 self.commitlogopts = environment.commitlogopts1305 self.showgraph = environment.refchange_showgraph1306 self.showlog = environment.refchange_showlog13071308 self.header_template = REFCHANGE_HEADER_TEMPLATE1309 self.intro_template = REFCHANGE_INTRO_TEMPLATE1310 self.footer_template = FOOTER_TEMPLATE13111312def_compute_values(self):1313 values = Change._compute_values(self)13141315 values['change_type'] = self.change_type1316 values['refname_type'] = self.refname_type1317 values['refname'] = self.refname1318 values['short_refname'] = self.short_refname1319 values['msgid'] = self.msgid1320 values['thread_index'] = self.thread_index1321 values['recipients'] = self.recipients1322 values['oldrev'] =str(self.old)1323 values['oldrev_short'] = self.old.short1324 values['newrev'] =str(self.new)1325 values['newrev_short'] = self.new.short13261327if self.old:1328 values['oldrev_type'] = self.old.type1329if self.new:1330 values['newrev_type'] = self.new.type13311332 reply_to = self.environment.get_reply_to_refchange(self)1333if reply_to:1334 values['reply_to'] = reply_to13351336return values13371338defsend_single_combined_email(self, known_added_sha1s):1339"""Determine if a combined refchange/revision email should be sent13401341 If there is only a single new (non-merge) commit added by a1342 change, it is useful to combine the ReferenceChange and1343 Revision emails into one. In such a case, return the single1344 revision; otherwise, return None.13451346 This method is overridden in BranchChange."""13471348return None13491350defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1351"""Generate an email describing this change AND specified revision.13521353 Iterate over the lines (including the header lines) of an1354 email describing this change. If body_filter is not None,1355 then use it to filter the lines that are intended for the1356 email body.13571358 The extra_header_values field is received as a dict and not as1359 **kwargs, to allow passing other keyword arguments in the1360 future (e.g. passing extra values to generate_email_intro()13611362 This method is overridden in BranchChange."""13631364raiseNotImplementedError13651366defget_subject(self):1367 template = {1368'create': REF_CREATED_SUBJECT_TEMPLATE,1369'update': REF_UPDATED_SUBJECT_TEMPLATE,1370'delete': REF_DELETED_SUBJECT_TEMPLATE,1371}[self.change_type]1372return self.expand(template)13731374defgenerate_email_header(self, **extra_values):1375if'subject'not in extra_values:1376 extra_values['subject'] = self.get_subject()13771378for line in self.expand_header_lines(1379 self.header_template, **extra_values1380):1381yield line13821383defgenerate_email_intro(self, html_escape_val=False):1384for line in self.expand_lines(self.intro_template,1385 html_escape_val=html_escape_val):1386yield line13871388defgenerate_email_body(self, push):1389"""Call the appropriate body-generation routine.13901391 Call one of generate_create_summary() /1392 generate_update_summary() / generate_delete_summary()."""13931394 change_summary = {1395'create': self.generate_create_summary,1396'delete': self.generate_delete_summary,1397'update': self.generate_update_summary,1398}[self.change_type](push)1399for line in change_summary:1400yield line14011402for line in self.generate_revision_change_summary(push):1403yield line14041405defgenerate_email_footer(self, html_escape_val):1406return self.expand_lines(self.footer_template,1407 html_escape_val=html_escape_val)14081409defgenerate_revision_change_graph(self, push):1410if self.showgraph:1411 args = ['--graph'] + self.graphopts1412for newold in('new','old'):1413 has_newold =False1414 spec = push.get_commits_spec(newold, self)1415for line ingit_log(spec, args=args, keepends=True):1416if not has_newold:1417 has_newold =True1418yield'\n'1419yield'Graph of%scommits:\n\n'% (1420 {'new': 'new', 'old': 'discarded'}[newold],)1421yield' '+ line1422if has_newold:1423yield'\n'14241425defgenerate_revision_change_log(self, new_commits_list):1426if self.showlog:1427yield'\n'1428yield'Detailed log of new commits:\n\n'1429for line inread_git_lines(1430['log','--no-walk'] +1431 self.logopts +1432 new_commits_list +1433['--'],1434 keepends=True,1435):1436yield line14371438defgenerate_new_revision_summary(self, tot, new_commits_list, push):1439for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):1440yield line1441for line in self.generate_revision_change_graph(push):1442yield line1443for line in self.generate_revision_change_log(new_commits_list):1444yield line14451446defgenerate_revision_change_summary(self, push):1447"""Generate a summary of the revisions added/removed by this change."""14481449if self.new.commit_sha1 and not self.old.commit_sha1:1450# A new reference was created. List the new revisions1451# brought by the new reference (i.e., those revisions that1452# were not in the repository before this reference1453# change).1454 sha1s =list(push.get_new_commits(self))1455 sha1s.reverse()1456 tot =len(sha1s)1457 new_revisions = [1458Revision(self,GitObject(sha1), num=i +1, tot=tot)1459for(i, sha1)inenumerate(sha1s)1460]14611462if new_revisions:1463yield self.expand('This%(refname_type)sincludes the following new commits:\n')1464yield'\n'1465for r in new_revisions:1466(sha1, subject) = r.rev.get_summary()1467yield r.expand(1468 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,1469)1470yield'\n'1471for line in self.generate_new_revision_summary(1472 tot, [r.rev.sha1 for r in new_revisions], push):1473yield line1474else:1475for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1476yield line14771478elif self.new.commit_sha1 and self.old.commit_sha1:1479# A reference was changed to point at a different commit.1480# List the revisions that were removed and/or added *from1481# that reference* by this reference change, along with a1482# diff between the trees for its old and new values.14831484# List of the revisions that were added to the branch by1485# this update. Note this list can include revisions that1486# have already had notification emails; we want such1487# revisions in the summary even though we will not send1488# new notification emails for them.1489 adds =list(generate_summaries(1490'--topo-order','--reverse','%s..%s'1491% (self.old.commit_sha1, self.new.commit_sha1,)1492))14931494# List of the revisions that were removed from the branch1495# by this update. This will be empty except for1496# non-fast-forward updates.1497 discards =list(generate_summaries(1498'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,)1499))15001501if adds:1502 new_commits_list = push.get_new_commits(self)1503else:1504 new_commits_list = []1505 new_commits =CommitSet(new_commits_list)15061507if discards:1508 discarded_commits =CommitSet(push.get_discarded_commits(self))1509else:1510 discarded_commits =CommitSet([])15111512if discards and adds:1513for(sha1, subject)in discards:1514if sha1 in discarded_commits:1515 action ='discard'1516else:1517 action ='omit'1518yield self.expand(1519 BRIEF_SUMMARY_TEMPLATE, action=action,1520 rev_short=sha1, text=subject,1521)1522for(sha1, subject)in adds:1523if sha1 in new_commits:1524 action ='new'1525else:1526 action ='add'1527yield self.expand(1528 BRIEF_SUMMARY_TEMPLATE, action=action,1529 rev_short=sha1, text=subject,1530)1531yield'\n'1532for line in self.expand_lines(NON_FF_TEMPLATE):1533yield line15341535elif discards:1536for(sha1, subject)in discards:1537if sha1 in discarded_commits:1538 action ='discard'1539else:1540 action ='omit'1541yield self.expand(1542 BRIEF_SUMMARY_TEMPLATE, action=action,1543 rev_short=sha1, text=subject,1544)1545yield'\n'1546for line in self.expand_lines(REWIND_ONLY_TEMPLATE):1547yield line15481549elif adds:1550(sha1, subject) = self.old.get_summary()1551yield self.expand(1552 BRIEF_SUMMARY_TEMPLATE, action='from',1553 rev_short=sha1, text=subject,1554)1555for(sha1, subject)in adds:1556if sha1 in new_commits:1557 action ='new'1558else:1559 action ='add'1560yield self.expand(1561 BRIEF_SUMMARY_TEMPLATE, action=action,1562 rev_short=sha1, text=subject,1563)15641565yield'\n'15661567if new_commits:1568for line in self.generate_new_revision_summary(1569len(new_commits), new_commits_list, push):1570yield line1571else:1572for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1573yield line1574for line in self.generate_revision_change_graph(push):1575yield line15761577# The diffstat is shown from the old revision to the new1578# revision. This is to show the truth of what happened in1579# this change. There's no point showing the stat from the1580# base to the new revision because the base is effectively a1581# random revision at this point - the user will be interested1582# in what this revision changed - including the undoing of1583# previous revisions in the case of non-fast-forward updates.1584yield'\n'1585yield'Summary of changes:\n'1586for line inread_git_lines(1587['diff-tree'] +1588 self.diffopts +1589['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1590 keepends=True,1591):1592yield line15931594elif self.old.commit_sha1 and not self.new.commit_sha1:1595# A reference was deleted. List the revisions that were1596# removed from the repository by this reference change.15971598 sha1s =list(push.get_discarded_commits(self))1599 tot =len(sha1s)1600 discarded_revisions = [1601Revision(self,GitObject(sha1), num=i +1, tot=tot)1602for(i, sha1)inenumerate(sha1s)1603]16041605if discarded_revisions:1606for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1607yield line1608yield'\n'1609for r in discarded_revisions:1610(sha1, subject) = r.rev.get_summary()1611yield r.expand(1612 BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,1613)1614for line in self.generate_revision_change_graph(push):1615yield line1616else:1617for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1618yield line16191620elif not self.old.commit_sha1 and not self.new.commit_sha1:1621for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1622yield line16231624defgenerate_create_summary(self, push):1625"""Called for the creation of a reference."""16261627# This is a new reference and so oldrev is not valid1628(sha1, subject) = self.new.get_summary()1629yield self.expand(1630 BRIEF_SUMMARY_TEMPLATE, action='at',1631 rev_short=sha1, text=subject,1632)1633yield'\n'16341635defgenerate_update_summary(self, push):1636"""Called for the change of a pre-existing branch."""16371638returniter([])16391640defgenerate_delete_summary(self, push):1641"""Called for the deletion of any type of reference."""16421643(sha1, subject) = self.old.get_summary()1644yield self.expand(1645 BRIEF_SUMMARY_TEMPLATE, action='was',1646 rev_short=sha1, text=subject,1647)1648yield'\n'16491650defget_specific_fromaddr(self):1651return self.environment.from_refchange165216531654classBranchChange(ReferenceChange):1655 refname_type ='branch'16561657def__init__(self, environment, refname, short_refname, old, new, rev):1658 ReferenceChange.__init__(1659 self, environment,1660 refname=refname, short_refname=short_refname,1661 old=old, new=new, rev=rev,1662)1663 self.recipients = environment.get_refchange_recipients(self)1664 self._single_revision =None16651666defsend_single_combined_email(self, known_added_sha1s):1667if not self.environment.combine_when_single_commit:1668return None16691670# In the sadly-all-too-frequent usecase of people pushing only1671# one of their commits at a time to a repository, users feel1672# the reference change summary emails are noise rather than1673# important signal. This is because, in this particular1674# usecase, there is a reference change summary email for each1675# new commit, and all these summaries do is point out that1676# there is one new commit (which can readily be inferred by1677# the existence of the individual revision email that is also1678# sent). In such cases, our users prefer there to be a combined1679# reference change summary/new revision email.1680#1681# So, if the change is an update and it doesn't discard any1682# commits, and it adds exactly one non-merge commit (gerrit1683# forces a workflow where every commit is individually merged1684# and the git-multimail hook fired off for just this one1685# change), then we send a combined refchange/revision email.1686try:1687# If this change is a reference update that doesn't discard1688# any commits...1689if self.change_type !='update':1690return None16911692ifread_git_lines(1693['merge-base', self.old.sha1, self.new.sha1]1694) != [self.old.sha1]:1695return None16961697# Check if this update introduced exactly one non-merge1698# commit:16991700defsplit_line(line):1701"""Split line into (sha1, [parent,...])."""17021703 words = line.split()1704return(words[0], words[1:])17051706# Get the new commits introduced by the push as a list of1707# (sha1, [parent,...])1708 new_commits = [1709split_line(line)1710for line inread_git_lines(1711[1712'log','-3','--format=%H %P',1713'%s..%s'% (self.old.sha1, self.new.sha1),1714]1715)1716]17171718if not new_commits:1719return None17201721# If the newest commit is a merge, save it for a later check1722# but otherwise ignore it1723 merge =None1724 tot =len(new_commits)1725iflen(new_commits[0][1]) >1:1726 merge = new_commits[0][0]1727del new_commits[0]17281729# Our primary check: we can't combine if more than one commit1730# is introduced. We also currently only combine if the new1731# commit is a non-merge commit, though it may make sense to1732# combine if it is a merge as well.1733if not(1734len(new_commits) ==1and1735len(new_commits[0][1]) ==1and1736 new_commits[0][0]in known_added_sha1s1737):1738return None17391740# We do not want to combine revision and refchange emails if1741# those go to separate locations.1742 rev =Revision(self,GitObject(new_commits[0][0]),1, tot)1743if rev.recipients != self.recipients:1744return None17451746# We ignored the newest commit if it was just a merge of the one1747# commit being introduced. But we don't want to ignore that1748# merge commit it it involved conflict resolutions. Check that.1749if merge and merge !=read_git_output(['diff-tree','--cc', merge]):1750return None17511752# We can combine the refchange and one new revision emails1753# into one. Return the Revision that a combined email should1754# be sent about.1755return rev1756except CommandError:1757# Cannot determine number of commits in old..new or new..old;1758# don't combine reference/revision emails:1759return None17601761defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1762 values = revision.get_values()1763if extra_header_values:1764 values.update(extra_header_values)1765if'subject'not in extra_header_values:1766 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)17671768 self._single_revision = revision1769 self._contains_diff()1770 self.header_template = COMBINED_HEADER_TEMPLATE1771 self.intro_template = COMBINED_INTRO_TEMPLATE1772 self.footer_template = COMBINED_FOOTER_TEMPLATE17731774defrevision_gen_link(base_url):1775# revision is used only to generate the body, and1776# _content_type is set while generating headers. Get it1777# from the BranchChange object.1778 revision._content_type = self._content_type1779return revision.generate_browse_link(base_url)1780 self.generate_browse_link = revision_gen_link1781for line in self.generate_email(push, body_filter, values):1782yield line17831784defgenerate_email_body(self, push):1785'''Call the appropriate body generation routine.17861787 If this is a combined refchange/revision email, the special logic1788 for handling this combined email comes from this function. For1789 other cases, we just use the normal handling.'''17901791# If self._single_revision isn't set; don't override1792if not self._single_revision:1793for line insuper(BranchChange, self).generate_email_body(push):1794yield line1795return17961797# This is a combined refchange/revision email; we first provide1798# some info from the refchange portion, and then call the revision1799# generate_email_body function to handle the revision portion.1800 adds =list(generate_summaries(1801'--topo-order','--reverse','%s..%s'1802% (self.old.commit_sha1, self.new.commit_sha1,)1803))18041805yield self.expand("The following commit(s) were added to%(refname)sby this push:\n")1806for(sha1, subject)in adds:1807yield self.expand(1808 BRIEF_SUMMARY_TEMPLATE, action='new',1809 rev_short=sha1, text=subject,1810)18111812yield self._single_revision.rev.short +" is described below\n"1813yield'\n'18141815for line in self._single_revision.generate_email_body(push):1816yield line181718181819classAnnotatedTagChange(ReferenceChange):1820 refname_type ='annotated tag'18211822def__init__(self, environment, refname, short_refname, old, new, rev):1823 ReferenceChange.__init__(1824 self, environment,1825 refname=refname, short_refname=short_refname,1826 old=old, new=new, rev=rev,1827)1828 self.recipients = environment.get_announce_recipients(self)1829 self.show_shortlog = environment.announce_show_shortlog18301831 ANNOTATED_TAG_FORMAT = (1832'%(*objectname)\n'1833'%(*objecttype)\n'1834'%(taggername)\n'1835'%(taggerdate)'1836)18371838defdescribe_tag(self, push):1839"""Describe the new value of an annotated tag."""18401841# Use git for-each-ref to pull out the individual fields from1842# the tag1843[tagobject, tagtype, tagger, tagged] =read_git_lines(1844['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1845)18461847yield self.expand(1848 BRIEF_SUMMARY_TEMPLATE, action='tagging',1849 rev_short=tagobject, text='(%s)'% (tagtype,),1850)1851if tagtype =='commit':1852# If the tagged object is a commit, then we assume this is a1853# release, and so we calculate which tag this tag is1854# replacing1855try:1856 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1857except CommandError:1858 prevtag =None1859if prevtag:1860yield' replaces%s\n'% (prevtag,)1861else:1862 prevtag =None1863yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)18641865yield' by%s\n'% (tagger,)1866yield' on%s\n'% (tagged,)1867yield'\n'18681869# Show the content of the tag message; this might contain a1870# change log or release notes so is worth displaying.1871yield LOGBEGIN1872 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1873 contents = contents[contents.index('\n') +1:]1874if contents and contents[-1][-1:] !='\n':1875 contents.append('\n')1876for line in contents:1877yield line18781879if self.show_shortlog and tagtype =='commit':1880# Only commit tags make sense to have rev-list operations1881# performed on them1882yield'\n'1883if prevtag:1884# Show changes since the previous release1885 revlist =read_git_output(1886['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1887 keepends=True,1888)1889else:1890# No previous tag, show all the changes since time1891# began1892 revlist =read_git_output(1893['rev-list','--pretty=short','%s'% (self.new,)],1894 keepends=True,1895)1896for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1897yield line18981899yield LOGEND1900yield'\n'19011902defgenerate_create_summary(self, push):1903"""Called for the creation of an annotated tag."""19041905for line in self.expand_lines(TAG_CREATED_TEMPLATE):1906yield line19071908for line in self.describe_tag(push):1909yield line19101911defgenerate_update_summary(self, push):1912"""Called for the update of an annotated tag.19131914 This is probably a rare event and may not even be allowed."""19151916for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1917yield line19181919for line in self.describe_tag(push):1920yield line19211922defgenerate_delete_summary(self, push):1923"""Called when a non-annotated reference is updated."""19241925for line in self.expand_lines(TAG_DELETED_TEMPLATE):1926yield line19271928yield self.expand(' tag was%(oldrev_short)s\n')1929yield'\n'193019311932classNonAnnotatedTagChange(ReferenceChange):1933 refname_type ='tag'19341935def__init__(self, environment, refname, short_refname, old, new, rev):1936 ReferenceChange.__init__(1937 self, environment,1938 refname=refname, short_refname=short_refname,1939 old=old, new=new, rev=rev,1940)1941 self.recipients = environment.get_refchange_recipients(self)19421943defgenerate_create_summary(self, push):1944"""Called for the creation of an annotated tag."""19451946for line in self.expand_lines(TAG_CREATED_TEMPLATE):1947yield line19481949defgenerate_update_summary(self, push):1950"""Called when a non-annotated reference is updated."""19511952for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1953yield line19541955defgenerate_delete_summary(self, push):1956"""Called when a non-annotated reference is updated."""19571958for line in self.expand_lines(TAG_DELETED_TEMPLATE):1959yield line19601961for line in ReferenceChange.generate_delete_summary(self, push):1962yield line196319641965classOtherReferenceChange(ReferenceChange):1966 refname_type ='reference'19671968def__init__(self, environment, refname, short_refname, old, new, rev):1969# We use the full refname as short_refname, because otherwise1970# the full name of the reference would not be obvious from the1971# text of the email.1972 ReferenceChange.__init__(1973 self, environment,1974 refname=refname, short_refname=refname,1975 old=old, new=new, rev=rev,1976)1977 self.recipients = environment.get_refchange_recipients(self)197819791980classMailer(object):1981"""An object that can send emails."""19821983def__init__(self, environment):1984 self.environment = environment19851986defclose(self):1987pass19881989defsend(self, lines, to_addrs):1990"""Send an email consisting of lines.19911992 lines must be an iterable over the lines constituting the1993 header and body of the email. to_addrs is a list of recipient1994 addresses (can be needed even if lines already contains a1995 "To:" field). It can be either a string (comma-separated list1996 of email addresses) or a Python list of individual email1997 addresses.19981999 """20002001raiseNotImplementedError()200220032004classSendMailer(Mailer):2005"""Send emails using 'sendmail -oi -t'."""20062007 SENDMAIL_CANDIDATES = [2008'/usr/sbin/sendmail',2009'/usr/lib/sendmail',2010]20112012@staticmethod2013deffind_sendmail():2014for path in SendMailer.SENDMAIL_CANDIDATES:2015if os.access(path, os.X_OK):2016return path2017else:2018raiseConfigurationException(2019'No sendmail executable found. '2020'Try setting multimailhook.sendmailCommand.'2021)20222023def__init__(self, environment, command=None, envelopesender=None):2024"""Construct a SendMailer instance.20252026 command should be the command and arguments used to invoke2027 sendmail, as a list of strings. If an envelopesender is2028 provided, it will also be passed to the command, via '-f2029 envelopesender'."""2030super(SendMailer, self).__init__(environment)2031if command:2032 self.command = command[:]2033else:2034 self.command = [self.find_sendmail(),'-oi','-t']20352036if envelopesender:2037 self.command.extend(['-f', envelopesender])20382039defsend(self, lines, to_addrs):2040try:2041 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)2042exceptOSError:2043 self.environment.get_logger().error(2044'*** Cannot execute command:%s\n'%' '.join(self.command) +2045'***%s\n'% sys.exc_info()[1] +2046'*** Try setting multimailhook.mailer to "smtp"\n'+2047'*** to send emails without using the sendmail command.\n'2048)2049 sys.exit(1)2050try:2051 lines = (str_to_bytes(line)for line in lines)2052 p.stdin.writelines(lines)2053exceptException:2054 self.environment.get_logger().error(2055'*** Error while generating commit email\n'2056'*** - mail sending aborted.\n'2057)2058ifhasattr(p,'terminate'):2059# subprocess.terminate() is not available in Python 2.42060 p.terminate()2061else:2062import signal2063 os.kill(p.pid, signal.SIGTERM)2064raise2065else:2066 p.stdin.close()2067 retcode = p.wait()2068if retcode:2069raiseCommandError(self.command, retcode)207020712072classSMTPMailer(Mailer):2073"""Send emails using Python's smtplib."""20742075def__init__(self, environment,2076 envelopesender, smtpserver,2077 smtpservertimeout=10.0, smtpserverdebuglevel=0,2078 smtpencryption='none',2079 smtpuser='', smtppass='',2080 smtpcacerts=''2081):2082super(SMTPMailer, self).__init__(environment)2083if not envelopesender:2084 self.environment.get_logger().error(2085'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'2086'please set either multimailhook.envelopeSender or user.email\n'2087)2088 sys.exit(1)2089if smtpencryption =='ssl'and not(smtpuser and smtppass):2090raiseConfigurationException(2091'Cannot use SMTPMailer with security option ssl '2092'without options username and password.'2093)2094 self.envelopesender = envelopesender2095 self.smtpserver = smtpserver2096 self.smtpservertimeout = smtpservertimeout2097 self.smtpserverdebuglevel = smtpserverdebuglevel2098 self.security = smtpencryption2099 self.username = smtpuser2100 self.password = smtppass2101 self.smtpcacerts = smtpcacerts2102 self.loggedin =False2103try:2104defcall(klass, server, timeout):2105try:2106returnklass(server, timeout=timeout)2107exceptTypeError:2108# Old Python versions do not have timeout= argument.2109returnklass(server)2110if self.security =='none':2111 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2112elif self.security =='ssl':2113if self.smtpcacerts:2114raise smtplib.SMTPException(2115"Checking certificate is not supported for ssl, prefer starttls"2116)2117 self.smtp =call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)2118elif self.security =='tls':2119if'ssl'not in sys.modules:2120 self.environment.get_logger().error(2121'*** Your Python version does not have the ssl library installed\n'2122'*** smtpEncryption=tls is not available.\n'2123'*** Either upgrade Python to 2.6 or later\n'2124' or use git_multimail.py version 1.2.\n')2125if':'not in self.smtpserver:2126 self.smtpserver +=':587'# default port for TLS2127 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2128# start: ehlo + starttls2129# equivalent to2130# self.smtp.ehlo()2131# self.smtp.starttls()2132# with acces to the ssl layer2133 self.smtp.ehlo()2134if not self.smtp.has_extn("starttls"):2135raise smtplib.SMTPException("STARTTLS extension not supported by server")2136 resp, reply = self.smtp.docmd("STARTTLS")2137if resp !=220:2138raise smtplib.SMTPException("Wrong answer to the STARTTLS command")2139if self.smtpcacerts:2140 self.smtp.sock = ssl.wrap_socket(2141 self.smtp.sock,2142 ca_certs=self.smtpcacerts,2143 cert_reqs=ssl.CERT_REQUIRED2144)2145else:2146 self.smtp.sock = ssl.wrap_socket(2147 self.smtp.sock,2148 cert_reqs=ssl.CERT_NONE2149)2150 self.environment.get_logger().error(2151'*** Warning, the server certificat is not verified (smtp) ***\n'2152'*** set the option smtpCACerts ***\n'2153)2154if nothasattr(self.smtp.sock,"read"):2155# using httplib.FakeSocket with Python 2.5.x or earlier2156 self.smtp.sock.read = self.smtp.sock.recv2157 self.smtp.file= smtplib.SSLFakeFile(self.smtp.sock)2158 self.smtp.helo_resp =None2159 self.smtp.ehlo_resp =None2160 self.smtp.esmtp_features = {}2161 self.smtp.does_esmtp =02162# end: ehlo + starttls2163 self.smtp.ehlo()2164else:2165 sys.stdout.write('*** Error: Control reached an invalid option. ***')2166 sys.exit(1)2167if self.smtpserverdebuglevel >0:2168 sys.stdout.write(2169"*** Setting debug on for SMTP server connection (%s) ***\n"2170% self.smtpserverdebuglevel)2171 self.smtp.set_debuglevel(self.smtpserverdebuglevel)2172exceptException:2173 self.environment.get_logger().error(2174'*** Error establishing SMTP connection to%s***\n'2175'***%s\n'2176% (self.smtpserver, sys.exc_info()[1]))2177 sys.exit(1)21782179defclose(self):2180ifhasattr(self,'smtp'):2181 self.smtp.quit()2182del self.smtp21832184def__del__(self):2185 self.close()21862187defsend(self, lines, to_addrs):2188try:2189if self.username or self.password:2190if not self.loggedin:2191 self.smtp.login(self.username, self.password)2192 self.loggedin =True2193 msg =''.join(lines)2194# turn comma-separated list into Python list if needed.2195ifis_string(to_addrs):2196 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]2197 self.smtp.sendmail(self.envelopesender, to_addrs, msg)2198except socket.timeout:2199 self.environment.get_logger().error(2200'*** Error sending email ***\n'2201'*** SMTP server timed out (timeout is%s)\n'2202% self.smtpservertimeout)2203except smtplib.SMTPResponseException:2204 err = sys.exc_info()[1]2205 self.environment.get_logger().error(2206'*** Error sending email ***\n'2207'*** Error%d:%s\n'2208% (err.smtp_code,bytes_to_str(err.smtp_error)))2209try:2210 smtp = self.smtp2211# delete the field before quit() so that in case of2212# error, self.smtp is deleted anyway.2213del self.smtp2214 smtp.quit()2215except:2216 self.environment.get_logger().error(2217'*** Error closing the SMTP connection ***\n'2218'*** Exiting anyway ... ***\n'2219'***%s\n'% sys.exc_info()[1])2220 sys.exit(1)222122222223classOutputMailer(Mailer):2224"""Write emails to an output stream, bracketed by lines of '=' characters.22252226 This is intended for debugging purposes."""22272228 SEPARATOR ='='*75+'\n'22292230def__init__(self, f, environment=None):2231super(OutputMailer, self).__init__(environment=environment)2232 self.f = f22332234defsend(self, lines, to_addrs):2235write_str(self.f, self.SEPARATOR)2236for line in lines:2237write_str(self.f, line)2238write_str(self.f, self.SEPARATOR)223922402241defget_git_dir():2242"""Determine GIT_DIR.22432244 Determine GIT_DIR either from the GIT_DIR environment variable or2245 from the working directory, using Git's usual rules."""22462247try:2248returnread_git_output(['rev-parse','--git-dir'])2249except CommandError:2250 sys.stderr.write('fatal: git_multimail: not in a git directory\n')2251 sys.exit(1)225222532254classEnvironment(object):2255"""Describes the environment in which the push is occurring.22562257 An Environment object encapsulates information about the local2258 environment. For example, it knows how to determine:22592260 * the name of the repository to which the push occurred22612262 * what user did the push22632264 * what users want to be informed about various types of changes.22652266 An Environment object is expected to have the following methods:22672268 get_repo_shortname()22692270 Return a short name for the repository, for display2271 purposes.22722273 get_repo_path()22742275 Return the absolute path to the Git repository.22762277 get_emailprefix()22782279 Return a string that will be prefixed to every email's2280 subject.22812282 get_pusher()22832284 Return the username of the person who pushed the changes.2285 This value is used in the email body to indicate who2286 pushed the change.22872288 get_pusher_email() (may return None)22892290 Return the email address of the person who pushed the2291 changes. The value should be a single RFC 2822 email2292 address as a string; e.g., "Joe User <user@example.com>"2293 if available, otherwise "user@example.com". If set, the2294 value is used as the Reply-To address for refchange2295 emails. If it is impossible to determine the pusher's2296 email, this attribute should be set to None (in which case2297 no Reply-To header will be output).22982299 get_sender()23002301 Return the address to be used as the 'From' email address2302 in the email envelope.23032304 get_fromaddr(change=None)23052306 Return the 'From' email address used in the email 'From:'2307 headers. If the change is known when this function is2308 called, it is passed in as the 'change' parameter. (May2309 be a full RFC 2822 email address like 'Joe User2310 <user@example.com>'.)23112312 get_administrator()23132314 Return the name and/or email of the repository2315 administrator. This value is used in the footer as the2316 person to whom requests to be removed from the2317 notification list should be sent. Ideally, it should2318 include a valid email address.23192320 get_reply_to_refchange()2321 get_reply_to_commit()23222323 Return the address to use in the email "Reply-To" header,2324 as a string. These can be an RFC 2822 email address, or2325 None to omit the "Reply-To" header.2326 get_reply_to_refchange() is used for refchange emails;2327 get_reply_to_commit() is used for individual commit2328 emails.23292330 get_ref_filter_regex()23312332 Return a tuple -- a compiled regex, and a boolean indicating2333 whether the regex picks refs to include (if False, the regex2334 matches on refs to exclude).23352336 get_default_ref_ignore_regex()23372338 Return a regex that should be ignored for both what emails2339 to send and when computing what commits are considered new2340 to the repository. Default is "^refs/notes/".23412342 get_max_subject_length()23432344 Return an int giving the maximal length for the subject2345 (git log --oneline).23462347 They should also define the following attributes:23482349 announce_show_shortlog (bool)23502351 True iff announce emails should include a shortlog.23522353 commit_email_format (string)23542355 If "html", generate commit emails in HTML instead of plain text2356 used by default.23572358 html_in_intro (bool)2359 html_in_footer (bool)23602361 When generating HTML emails, the introduction (respectively,2362 the footer) will be HTML-escaped iff html_in_intro (respectively,2363 the footer) is true. When false, only the values used to expand2364 the template are escaped.23652366 refchange_showgraph (bool)23672368 True iff refchanges emails should include a detailed graph.23692370 refchange_showlog (bool)23712372 True iff refchanges emails should include a detailed log.23732374 diffopts (list of strings)23752376 The options that should be passed to 'git diff' for the2377 summary email. The value should be a list of strings2378 representing words to be passed to the command.23792380 graphopts (list of strings)23812382 Analogous to diffopts, but contains options passed to2383 'git log --graph' when generating the detailed graph for2384 a set of commits (see refchange_showgraph)23852386 logopts (list of strings)23872388 Analogous to diffopts, but contains options passed to2389 'git log' when generating the detailed log for a set of2390 commits (see refchange_showlog)23912392 commitlogopts (list of strings)23932394 The options that should be passed to 'git log' for each2395 commit mail. The value should be a list of strings2396 representing words to be passed to the command.23972398 date_substitute (string)23992400 String to be used in substitution for 'Date:' at start of2401 line in the output of 'git log'.24022403 quiet (bool)2404 On success do not write to stderr24052406 stdout (bool)2407 Write email to stdout rather than emailing. Useful for debugging24082409 combine_when_single_commit (bool)24102411 True if a combined email should be produced when a single2412 new commit is pushed to a branch, False otherwise.24132414 from_refchange, from_commit (strings)24152416 Addresses to use for the From: field for refchange emails2417 and commit emails respectively. Set from2418 multimailhook.fromRefchange and multimailhook.fromCommit2419 by ConfigEnvironmentMixin.24202421 log_file, error_log_file, debug_log_file (string)24222423 Name of a file to which logs should be sent.24242425 verbose (int)24262427 How verbose the system should be.2428 - 0 (default): show info, errors, ...2429 - 1 : show basic debug info2430 """24312432 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')24332434def__init__(self, osenv=None):2435 self.osenv = osenv or os.environ2436 self.announce_show_shortlog =False2437 self.commit_email_format ="text"2438 self.html_in_intro =False2439 self.html_in_footer =False2440 self.commitBrowseURL =None2441 self.maxcommitemails =5002442 self.excludemergerevisions =False2443 self.diffopts = ['--stat','--summary','--find-copies-harder']2444 self.graphopts = ['--oneline','--decorate']2445 self.logopts = []2446 self.refchange_showgraph =False2447 self.refchange_showlog =False2448 self.commitlogopts = ['-C','--stat','-p','--cc']2449 self.date_substitute ='AuthorDate: '2450 self.quiet =False2451 self.stdout =False2452 self.combine_when_single_commit =True2453 self.logger =None24542455 self.COMPUTED_KEYS = [2456'administrator',2457'charset',2458'emailprefix',2459'pusher',2460'pusher_email',2461'repo_path',2462'repo_shortname',2463'sender',2464]24652466 self._values =None24672468defget_logger(self):2469"""Get (possibly creates) the logger associated to this environment."""2470if self.logger is None:2471 self.logger =Logger(self)2472return self.logger24732474defget_repo_shortname(self):2475"""Use the last part of the repo path, with ".git" stripped off if present."""24762477 basename = os.path.basename(os.path.abspath(self.get_repo_path()))2478 m = self.REPO_NAME_RE.match(basename)2479if m:2480return m.group('name')2481else:2482return basename24832484defget_pusher(self):2485raiseNotImplementedError()24862487defget_pusher_email(self):2488return None24892490defget_fromaddr(self, change=None):2491 config =Config('user')2492 fromname = config.get('name', default='')2493 fromemail = config.get('email', default='')2494if fromemail:2495returnformataddr([fromname, fromemail])2496return self.get_sender()24972498defget_administrator(self):2499return'the administrator of this repository'25002501defget_emailprefix(self):2502return''25032504defget_repo_path(self):2505ifread_git_output(['rev-parse','--is-bare-repository']) =='true':2506 path =get_git_dir()2507else:2508 path =read_git_output(['rev-parse','--show-toplevel'])2509return os.path.abspath(path)25102511defget_charset(self):2512return CHARSET25132514defget_values(self):2515"""Return a dictionary{keyword: expansion}for this Environment.25162517 This method is called by Change._compute_values(). The keys2518 in the returned dictionary are available to be used in any of2519 the templates. The dictionary is created by calling2520 self.get_NAME() for each of the attributes named in2521 COMPUTED_KEYS and recording those that do not return None.2522 The return value is always a new dictionary."""25232524if self._values is None:2525 values = {'': ''} # %()s expands to the empty string.25262527for key in self.COMPUTED_KEYS:2528 value =getattr(self,'get_%s'% (key,))()2529if value is not None:2530 values[key] = value25312532 self._values = values25332534return self._values.copy()25352536defget_refchange_recipients(self, refchange):2537"""Return the recipients for notifications about refchange.25382539 Return the list of email addresses to which notifications2540 about the specified ReferenceChange should be sent."""25412542raiseNotImplementedError()25432544defget_announce_recipients(self, annotated_tag_change):2545"""Return the recipients for notifications about annotated_tag_change.25462547 Return the list of email addresses to which notifications2548 about the specified AnnotatedTagChange should be sent."""25492550raiseNotImplementedError()25512552defget_reply_to_refchange(self, refchange):2553return self.get_pusher_email()25542555defget_revision_recipients(self, revision):2556"""Return the recipients for messages about revision.25572558 Return the list of email addresses to which notifications2559 about the specified Revision should be sent. This method2560 could be overridden, for example, to take into account the2561 contents of the revision when deciding whom to notify about2562 it. For example, there could be a scheme for users to express2563 interest in particular files or subdirectories, and only2564 receive notification emails for revisions that affecting those2565 files."""25662567raiseNotImplementedError()25682569defget_reply_to_commit(self, revision):2570return revision.author25712572defget_default_ref_ignore_regex(self):2573# The commit messages of git notes are essentially meaningless2574# and "filenames" in git notes commits are an implementational2575# detail that might surprise users at first. As such, we2576# would need a completely different method for handling emails2577# of git notes in order for them to be of benefit for users,2578# which we simply do not have right now.2579return"^refs/notes/"25802581defget_max_subject_length(self):2582"""Return the maximal subject line (git log --oneline) length.2583 Longer subject lines will be truncated."""2584raiseNotImplementedError()25852586deffilter_body(self, lines):2587"""Filter the lines intended for an email body.25882589 lines is an iterable over the lines that would go into the2590 email body. Filter it (e.g., limit the number of lines, the2591 line length, character set, etc.), returning another iterable.2592 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin2593 for classes implementing this functionality."""25942595return lines25962597deflog_msg(self, msg):2598"""Write the string msg on a log file or on stderr.25992600 Sends the text to stderr by default, override to change the behavior."""2601 self.get_logger().info(msg)26022603deflog_warning(self, msg):2604"""Write the string msg on a log file or on stderr.26052606 Sends the text to stderr by default, override to change the behavior."""2607 self.get_logger().warning(msg)26082609deflog_error(self, msg):2610"""Write the string msg on a log file or on stderr.26112612 Sends the text to stderr by default, override to change the behavior."""2613 self.get_logger().error(msg)26142615defcheck(self):2616pass261726182619classConfigEnvironmentMixin(Environment):2620"""A mixin that sets self.config to its constructor's config argument.26212622 This class's constructor consumes the "config" argument.26232624 Mixins that need to inspect the config should inherit from this2625 class (1) to make sure that "config" is still in the constructor2626 arguments with its own constructor runs and/or (2) to be sure that2627 self.config is set after construction."""26282629def__init__(self, config, **kw):2630super(ConfigEnvironmentMixin, self).__init__(**kw)2631 self.config = config263226332634classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):2635"""An Environment that reads most of its information from "git config"."""26362637@staticmethod2638defforbid_field_values(name, value, forbidden):2639for forbidden_val in forbidden:2640if value is not None and value.lower() == forbidden:2641raiseConfigurationException(2642'"%s" is not an allowed setting for%s'% (value, name)2643)26442645def__init__(self, config, **kw):2646super(ConfigOptionsEnvironmentMixin, self).__init__(2647 config=config, **kw2648)26492650for var, cfg in(2651('announce_show_shortlog','announceshortlog'),2652('refchange_showgraph','refchangeShowGraph'),2653('refchange_showlog','refchangeshowlog'),2654('quiet','quiet'),2655('stdout','stdout'),2656):2657 val = config.get_bool(cfg)2658if val is not None:2659setattr(self, var, val)26602661 commit_email_format = config.get('commitEmailFormat')2662if commit_email_format is not None:2663if commit_email_format !="html"and commit_email_format !="text":2664 self.log_warning(2665'*** Unknown value for multimailhook.commitEmailFormat:%s\n'%2666 commit_email_format +2667'*** Expected either "text" or "html". Ignoring.\n'2668)2669else:2670 self.commit_email_format = commit_email_format26712672 html_in_intro = config.get_bool('htmlInIntro')2673if html_in_intro is not None:2674 self.html_in_intro = html_in_intro26752676 html_in_footer = config.get_bool('htmlInFooter')2677if html_in_footer is not None:2678 self.html_in_footer = html_in_footer26792680 self.commitBrowseURL = config.get('commitBrowseURL')26812682 self.excludemergerevisions = config.get('excludeMergeRevisions')26832684 maxcommitemails = config.get('maxcommitemails')2685if maxcommitemails is not None:2686try:2687 self.maxcommitemails =int(maxcommitemails)2688exceptValueError:2689 self.log_warning(2690'*** Malformed value for multimailhook.maxCommitEmails:%s\n'2691% maxcommitemails +2692'*** Expected a number. Ignoring.\n'2693)26942695 diffopts = config.get('diffopts')2696if diffopts is not None:2697 self.diffopts = shlex.split(diffopts)26982699 graphopts = config.get('graphOpts')2700if graphopts is not None:2701 self.graphopts = shlex.split(graphopts)27022703 logopts = config.get('logopts')2704if logopts is not None:2705 self.logopts = shlex.split(logopts)27062707 commitlogopts = config.get('commitlogopts')2708if commitlogopts is not None:2709 self.commitlogopts = shlex.split(commitlogopts)27102711 date_substitute = config.get('dateSubstitute')2712if date_substitute =='none':2713 self.date_substitute =None2714elif date_substitute is not None:2715 self.date_substitute = date_substitute27162717 reply_to = config.get('replyTo')2718 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)2719 self.forbid_field_values('replyToRefchange',2720 self.__reply_to_refchange,2721['author'])2722 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)27232724 self.from_refchange = config.get('fromRefchange')2725 self.forbid_field_values('fromRefchange',2726 self.from_refchange,2727['author','none'])2728 self.from_commit = config.get('fromCommit')2729 self.forbid_field_values('fromCommit',2730 self.from_commit,2731['none'])27322733 combine = config.get_bool('combineWhenSingleCommit')2734if combine is not None:2735 self.combine_when_single_commit = combine27362737 self.log_file = config.get('logFile', default=None)2738 self.error_log_file = config.get('errorLogFile', default=None)2739 self.debug_log_file = config.get('debugLogFile', default=None)2740if config.get_bool('Verbose', default=False):2741 self.verbose =12742else:2743 self.verbose =027442745defget_administrator(self):2746return(2747 self.config.get('administrator')or2748 self.get_sender()or2749super(ConfigOptionsEnvironmentMixin, self).get_administrator()2750)27512752defget_repo_shortname(self):2753return(2754 self.config.get('reponame')or2755super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()2756)27572758defget_emailprefix(self):2759 emailprefix = self.config.get('emailprefix')2760if emailprefix is not None:2761 emailprefix = emailprefix.strip()2762if emailprefix:2763 emailprefix +=' '2764else:2765 emailprefix ='[%(repo_shortname)s] '2766 short_name = self.get_repo_shortname()2767try:2768return emailprefix % {'repo_shortname': short_name}2769except:2770 self.get_logger().error(2771'*** Invalid multimailhook.emailPrefix:%s\n'% emailprefix +2772'***%s\n'% sys.exc_info()[1] +2773"*** Only the '%(repo_shortname)s' placeholder is allowed\n"2774)2775raiseConfigurationException(2776'"%s" is not an allowed setting for emailPrefix'% emailprefix2777)27782779defget_sender(self):2780return self.config.get('envelopesender')27812782defprocess_addr(self, addr, change):2783if addr.lower() =='author':2784ifhasattr(change,'author'):2785return change.author2786else:2787return None2788elif addr.lower() =='pusher':2789return self.get_pusher_email()2790elif addr.lower() =='none':2791return None2792else:2793return addr27942795defget_fromaddr(self, change=None):2796 fromaddr = self.config.get('from')2797if change:2798 specific_fromaddr = change.get_specific_fromaddr()2799if specific_fromaddr:2800 fromaddr = specific_fromaddr2801if fromaddr:2802 fromaddr = self.process_addr(fromaddr, change)2803if fromaddr:2804return fromaddr2805returnsuper(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)28062807defget_reply_to_refchange(self, refchange):2808if self.__reply_to_refchange is None:2809returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)2810else:2811return self.process_addr(self.__reply_to_refchange, refchange)28122813defget_reply_to_commit(self, revision):2814if self.__reply_to_commit is None:2815returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)2816else:2817return self.process_addr(self.__reply_to_commit, revision)28182819defget_scancommitforcc(self):2820return self.config.get('scancommitforcc')282128222823classFilterLinesEnvironmentMixin(Environment):2824"""Handle encoding and maximum line length of body lines.28252826 email_max_line_length (int or None)28272828 The maximum length of any single line in the email body.2829 Longer lines are truncated at that length with ' [...]'2830 appended.28312832 strict_utf8 (bool)28332834 If this field is set to True, then the email body text is2835 expected to be UTF-8. Any invalid characters are2836 converted to U+FFFD, the Unicode replacement character2837 (encoded as UTF-8, of course).28382839 """28402841def__init__(self, strict_utf8=True,2842 email_max_line_length=500, max_subject_length=500,2843**kw):2844super(FilterLinesEnvironmentMixin, self).__init__(**kw)2845 self.__strict_utf8= strict_utf82846 self.__email_max_line_length = email_max_line_length2847 self.__max_subject_length = max_subject_length28482849deffilter_body(self, lines):2850 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)2851if self.__strict_utf8:2852if not PYTHON3:2853 lines = (line.decode(ENCODING,'replace')for line in lines)2854# Limit the line length in Unicode-space to avoid2855# splitting characters:2856if self.__email_max_line_length >0:2857 lines =limit_linelength(lines, self.__email_max_line_length)2858if not PYTHON3:2859 lines = (line.encode(ENCODING,'replace')for line in lines)2860elif self.__email_max_line_length:2861 lines =limit_linelength(lines, self.__email_max_line_length)28622863return lines28642865defget_max_subject_length(self):2866return self.__max_subject_length286728682869classConfigFilterLinesEnvironmentMixin(2870 ConfigEnvironmentMixin,2871 FilterLinesEnvironmentMixin,2872):2873"""Handle encoding and maximum line length based on config."""28742875def__init__(self, config, **kw):2876 strict_utf8 = config.get_bool('emailstrictutf8', default=None)2877if strict_utf8 is not None:2878 kw['strict_utf8'] = strict_utf828792880 email_max_line_length = config.get('emailmaxlinelength')2881if email_max_line_length is not None:2882 kw['email_max_line_length'] =int(email_max_line_length)28832884 max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)2885if max_subject_length is not None:2886 kw['max_subject_length'] =int(max_subject_length)28872888super(ConfigFilterLinesEnvironmentMixin, self).__init__(2889 config=config, **kw2890)289128922893classMaxlinesEnvironmentMixin(Environment):2894"""Limit the email body to a specified number of lines."""28952896def__init__(self, emailmaxlines, **kw):2897super(MaxlinesEnvironmentMixin, self).__init__(**kw)2898 self.__emailmaxlines = emailmaxlines28992900deffilter_body(self, lines):2901 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)2902if self.__emailmaxlines >0:2903 lines =limit_lines(lines, self.__emailmaxlines)2904return lines290529062907classConfigMaxlinesEnvironmentMixin(2908 ConfigEnvironmentMixin,2909 MaxlinesEnvironmentMixin,2910):2911"""Limit the email body to the number of lines specified in config."""29122913def__init__(self, config, **kw):2914 emailmaxlines =int(config.get('emailmaxlines', default='0'))2915super(ConfigMaxlinesEnvironmentMixin, self).__init__(2916 config=config,2917 emailmaxlines=emailmaxlines,2918**kw2919)292029212922classFQDNEnvironmentMixin(Environment):2923"""A mixin that sets the host's FQDN to its constructor argument."""29242925def__init__(self, fqdn, **kw):2926super(FQDNEnvironmentMixin, self).__init__(**kw)2927 self.COMPUTED_KEYS += ['fqdn']2928 self.__fqdn = fqdn29292930defget_fqdn(self):2931"""Return the fully-qualified domain name for this host.29322933 Return None if it is unavailable or unwanted."""29342935return self.__fqdn293629372938classConfigFQDNEnvironmentMixin(2939 ConfigEnvironmentMixin,2940 FQDNEnvironmentMixin,2941):2942"""Read the FQDN from the config."""29432944def__init__(self, config, **kw):2945 fqdn = config.get('fqdn')2946super(ConfigFQDNEnvironmentMixin, self).__init__(2947 config=config,2948 fqdn=fqdn,2949**kw2950)295129522953classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):2954"""Get the FQDN by calling socket.getfqdn()."""29552956def__init__(self, **kw):2957super(ComputeFQDNEnvironmentMixin, self).__init__(2958 fqdn=socket.getfqdn(),2959**kw2960)296129622963classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):2964"""Deduce pusher_email from pusher by appending an emaildomain."""29652966def__init__(self, **kw):2967super(PusherDomainEnvironmentMixin, self).__init__(**kw)2968 self.__emaildomain = self.config.get('emaildomain')29692970defget_pusher_email(self):2971if self.__emaildomain:2972# Derive the pusher's full email address in the default way:2973return'%s@%s'% (self.get_pusher(), self.__emaildomain)2974else:2975returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()297629772978classStaticRecipientsEnvironmentMixin(Environment):2979"""Set recipients statically based on constructor parameters."""29802981def__init__(2982 self,2983 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,2984**kw2985):2986super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)29872988# The recipients for various types of notification emails, as2989# RFC 2822 email addresses separated by commas (or the empty2990# string if no recipients are configured). Although there is2991# a mechanism to choose the recipient lists based on on the2992# actual *contents* of the change being reported, we only2993# choose based on the *type* of the change. Therefore we can2994# compute them once and for all:2995 self.__refchange_recipients = refchange_recipients2996 self.__announce_recipients = announce_recipients2997 self.__revision_recipients = revision_recipients29982999defcheck(self):3000if not(self.get_refchange_recipients(None)or3001 self.get_announce_recipients(None)or3002 self.get_revision_recipients(None)or3003 self.get_scancommitforcc()):3004raiseConfigurationException('No email recipients configured!')3005super(StaticRecipientsEnvironmentMixin, self).check()30063007defget_refchange_recipients(self, refchange):3008if self.__refchange_recipients is None:3009returnsuper(StaticRecipientsEnvironmentMixin,3010 self).get_refchange_recipients(refchange)3011return self.__refchange_recipients30123013defget_announce_recipients(self, annotated_tag_change):3014if self.__announce_recipients is None:3015returnsuper(StaticRecipientsEnvironmentMixin,3016 self).get_refchange_recipients(annotated_tag_change)3017return self.__announce_recipients30183019defget_revision_recipients(self, revision):3020if self.__revision_recipients is None:3021returnsuper(StaticRecipientsEnvironmentMixin,3022 self).get_refchange_recipients(revision)3023return self.__revision_recipients302430253026classCLIRecipientsEnvironmentMixin(Environment):3027"""Mixin storing recipients information coming from the3028 command-line."""30293030def__init__(self, cli_recipients=None, **kw):3031super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)3032 self.__cli_recipients = cli_recipients30333034defget_refchange_recipients(self, refchange):3035if self.__cli_recipients is None:3036returnsuper(CLIRecipientsEnvironmentMixin,3037 self).get_refchange_recipients(refchange)3038return self.__cli_recipients30393040defget_announce_recipients(self, annotated_tag_change):3041if self.__cli_recipients is None:3042returnsuper(CLIRecipientsEnvironmentMixin,3043 self).get_announce_recipients(annotated_tag_change)3044return self.__cli_recipients30453046defget_revision_recipients(self, revision):3047if self.__cli_recipients is None:3048returnsuper(CLIRecipientsEnvironmentMixin,3049 self).get_revision_recipients(revision)3050return self.__cli_recipients305130523053classConfigRecipientsEnvironmentMixin(3054 ConfigEnvironmentMixin,3055 StaticRecipientsEnvironmentMixin3056):3057"""Determine recipients statically based on config."""30583059def__init__(self, config, **kw):3060super(ConfigRecipientsEnvironmentMixin, self).__init__(3061 config=config,3062 refchange_recipients=self._get_recipients(3063 config,'refchangelist','mailinglist',3064),3065 announce_recipients=self._get_recipients(3066 config,'announcelist','refchangelist','mailinglist',3067),3068 revision_recipients=self._get_recipients(3069 config,'commitlist','mailinglist',3070),3071 scancommitforcc=config.get('scancommitforcc'),3072**kw3073)30743075def_get_recipients(self, config, *names):3076"""Return the recipients for a particular type of message.30773078 Return the list of email addresses to which a particular type3079 of notification email should be sent, by looking at the config3080 value for "multimailhook.$name" for each of names. Use the3081 value from the first name that is configured. The return3082 value is a (possibly empty) string containing RFC 2822 email3083 addresses separated by commas. If no configuration could be3084 found, raise a ConfigurationException."""30853086for name in names:3087 lines = config.get_all(name)3088if lines is not None:3089 lines = [line.strip()for line in lines]3090# Single "none" is a special value equivalen to empty string.3091if lines == ['none']:3092 lines = ['']3093return', '.join(lines)3094else:3095return''309630973098classStaticRefFilterEnvironmentMixin(Environment):3099"""Set branch filter statically based on constructor parameters."""31003101def__init__(self, ref_filter_incl_regex, ref_filter_excl_regex,3102 ref_filter_do_send_regex, ref_filter_dont_send_regex,3103**kw):3104super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)31053106if ref_filter_incl_regex and ref_filter_excl_regex:3107raiseConfigurationException(3108"Cannot specify both a ref inclusion and exclusion regex.")3109 self.__is_inclusion_filter =bool(ref_filter_incl_regex)3110 default_exclude = self.get_default_ref_ignore_regex()3111if ref_filter_incl_regex:3112 ref_filter_regex = ref_filter_incl_regex3113elif ref_filter_excl_regex:3114 ref_filter_regex = ref_filter_excl_regex +'|'+ default_exclude3115else:3116 ref_filter_regex = default_exclude3117try:3118 self.__compiled_regex = re.compile(ref_filter_regex)3119exceptException:3120raiseConfigurationException(3121'Invalid Ref Filter Regex "%s":%s'% (ref_filter_regex, sys.exc_info()[1]))31223123if ref_filter_do_send_regex and ref_filter_dont_send_regex:3124raiseConfigurationException(3125"Cannot specify both a ref doSend and dontSend regex.")3126 self.__is_do_send_filter =bool(ref_filter_do_send_regex)3127if ref_filter_do_send_regex:3128 ref_filter_send_regex = ref_filter_do_send_regex3129elif ref_filter_dont_send_regex:3130 ref_filter_send_regex = ref_filter_dont_send_regex3131else:3132 ref_filter_send_regex ='.*'3133 self.__is_do_send_filter =True3134try:3135 self.__send_compiled_regex = re.compile(ref_filter_send_regex)3136exceptException:3137raiseConfigurationException(3138'Invalid Ref Filter Regex "%s":%s'%3139(ref_filter_send_regex, sys.exc_info()[1]))31403141defget_ref_filter_regex(self, send_filter=False):3142if send_filter:3143return self.__send_compiled_regex, self.__is_do_send_filter3144else:3145return self.__compiled_regex, self.__is_inclusion_filter314631473148classConfigRefFilterEnvironmentMixin(3149 ConfigEnvironmentMixin,3150 StaticRefFilterEnvironmentMixin3151):3152"""Determine branch filtering statically based on config."""31533154def_get_regex(self, config, key):3155"""Get a list of whitespace-separated regex. The refFilter* config3156 variables are multivalued (hence the use of get_all), and we3157 allow each entry to be a whitespace-separated list (hence the3158 split on each line). The whole thing is glued into a single regex."""3159 values = config.get_all(key)3160if values is None:3161return values3162 items = []3163for line in values:3164for i in line.split():3165 items.append(i)3166if items == []:3167return None3168return'|'.join(items)31693170def__init__(self, config, **kw):3171super(ConfigRefFilterEnvironmentMixin, self).__init__(3172 config=config,3173 ref_filter_incl_regex=self._get_regex(config,'refFilterInclusionRegex'),3174 ref_filter_excl_regex=self._get_regex(config,'refFilterExclusionRegex'),3175 ref_filter_do_send_regex=self._get_regex(config,'refFilterDoSendRegex'),3176 ref_filter_dont_send_regex=self._get_regex(config,'refFilterDontSendRegex'),3177**kw3178)317931803181classProjectdescEnvironmentMixin(Environment):3182"""Make a "projectdesc" value available for templates.31833184 By default, it is set to the first line of $GIT_DIR/description3185 (if that file is present and appears to be set meaningfully)."""31863187def__init__(self, **kw):3188super(ProjectdescEnvironmentMixin, self).__init__(**kw)3189 self.COMPUTED_KEYS += ['projectdesc']31903191defget_projectdesc(self):3192"""Return a one-line descripition of the project."""31933194 git_dir =get_git_dir()3195try:3196 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()3197if projectdesc and not projectdesc.startswith('Unnamed repository'):3198return projectdesc3199exceptIOError:3200pass32013202return'UNNAMED PROJECT'320332043205classGenericEnvironmentMixin(Environment):3206defget_pusher(self):3207return self.osenv.get('USER', self.osenv.get('USERNAME','unknown user'))320832093210classGitoliteEnvironmentHighPrecMixin(Environment):3211defget_pusher(self):3212return self.osenv.get('GL_USER','unknown user')321332143215classGitoliteEnvironmentLowPrecMixin(3216 ConfigEnvironmentMixin,3217 Environment):32183219defget_repo_shortname(self):3220# The gitolite environment variable $GL_REPO is a pretty good3221# repo_shortname (though it's probably not as good as a value3222# the user might have explicitly put in his config).3223return(3224 self.osenv.get('GL_REPO',None)or3225super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()3226)32273228@staticmethod3229def_compile_regex(re_template):3230return(3231 re.compile(re_template % x)3232for x in(3233 r'BEGIN\s+USER\s+EMAILS',3234 r'([^\s]+)\s+(.*)',3235 r'END\s+USER\s+EMAILS',3236))32373238defget_fromaddr(self, change=None):3239 GL_USER = self.osenv.get('GL_USER')3240if GL_USER is not None:3241# Find the path to gitolite.conf. Note that gitolite v33242# did away with the GL_ADMINDIR and GL_CONF environment3243# variables (they are now hard-coded).3244 GL_ADMINDIR = self.osenv.get(3245'GL_ADMINDIR',3246 os.path.expanduser(os.path.join('~','.gitolite')))3247 GL_CONF = self.osenv.get(3248'GL_CONF',3249 os.path.join(GL_ADMINDIR,'conf','gitolite.conf'))32503251 mailaddress_map = self.config.get('MailaddressMap')3252# If relative, consider relative to GL_CONF:3253if mailaddress_map:3254 mailaddress_map = os.path.join(os.path.dirname(GL_CONF),3255 mailaddress_map)3256if os.path.isfile(mailaddress_map):3257 f =open(mailaddress_map,'rU')3258try:3259# Leading '#' is optional3260 re_begin, re_user, re_end = self._compile_regex(3261 r'^(?:\s*#)?\s*%s\s*$')3262for l in f:3263 l = l.rstrip('\n')3264if re_begin.match(l)or re_end.match(l):3265continue# Ignore these lines3266 m = re_user.match(l)3267if m:3268if m.group(1) == GL_USER:3269return m.group(2)3270else:3271continue# Not this user, but not an error3272raiseConfigurationException(3273"Syntax error in mail address map.\n"3274"Check file {}.\n"3275"Line: {}".format(mailaddress_map, l))32763277finally:3278 f.close()32793280if os.path.isfile(GL_CONF):3281 f =open(GL_CONF,'rU')3282try:3283 in_user_emails_section =False3284 re_begin, re_user, re_end = self._compile_regex(3285 r'^\s*#\s*%s\s*$')3286for l in f:3287 l = l.rstrip('\n')3288if not in_user_emails_section:3289if re_begin.match(l):3290 in_user_emails_section =True3291continue3292if re_end.match(l):3293break3294 m = re_user.match(l)3295if m and m.group(1) == GL_USER:3296return m.group(2)3297finally:3298 f.close()3299returnsuper(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)330033013302classIncrementalDateTime(object):3303"""Simple wrapper to give incremental date/times.33043305 Each call will result in a date/time a second later than the3306 previous call. This can be used to falsify email headers, to3307 increase the likelihood that email clients sort the emails3308 correctly."""33093310def__init__(self):3311 self.time = time.time()3312 self.next = self.__next__# Python 2 backward compatibility33133314def__next__(self):3315 formatted =formatdate(self.time,True)3316 self.time +=13317return formatted331833193320classStashEnvironmentHighPrecMixin(Environment):3321def__init__(self, user=None, repo=None, **kw):3322super(StashEnvironmentHighPrecMixin,3323 self).__init__(user=user, repo=repo, **kw)3324 self.__user = user3325 self.__repo = repo33263327defget_pusher(self):3328return re.match(r'(.*?)\s*<', self.__user).group(1)33293330defget_pusher_email(self):3331return self.__user333233333334classStashEnvironmentLowPrecMixin(Environment):3335def__init__(self, user=None, repo=None, **kw):3336super(StashEnvironmentLowPrecMixin, self).__init__(**kw)3337 self.__repo = repo3338 self.__user = user33393340defget_repo_shortname(self):3341return self.__repo33423343defget_fromaddr(self, change=None):3344return self.__user334533463347classGerritEnvironmentHighPrecMixin(Environment):3348def__init__(self, project=None, submitter=None, update_method=None, **kw):3349super(GerritEnvironmentHighPrecMixin,3350 self).__init__(submitter=submitter, project=project, **kw)3351 self.__project = project3352 self.__submitter = submitter3353 self.__update_method = update_method3354"Make an 'update_method' value available for templates."3355 self.COMPUTED_KEYS += ['update_method']33563357defget_pusher(self):3358if self.__submitter:3359if self.__submitter.find('<') != -1:3360# Submitter has a configured email, we transformed3361# __submitter into an RFC 2822 string already.3362return re.match(r'(.*?)\s*<', self.__submitter).group(1)3363else:3364# Submitter has no configured email, it's just his name.3365return self.__submitter3366else:3367# If we arrive here, this means someone pushed "Submit" from3368# the gerrit web UI for the CR (or used one of the programmatic3369# APIs to do the same, such as gerrit review) and the3370# merge/push was done by the Gerrit user. It was technically3371# triggered by someone else, but sadly we have no way of3372# determining who that someone else is at this point.3373return'Gerrit'# 'unknown user'?33743375defget_pusher_email(self):3376if self.__submitter:3377return self.__submitter3378else:3379returnsuper(GerritEnvironmentHighPrecMixin, self).get_pusher_email()33803381defget_default_ref_ignore_regex(self):3382 default =super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex()3383return default +'|^refs/changes/|^refs/cache-automerge/|^refs/meta/'33843385defget_revision_recipients(self, revision):3386# Merge commits created by Gerrit when users hit "Submit this patchset"3387# in the Web UI (or do equivalently with REST APIs or the gerrit review3388# command) are not something users want to see an individual email for.3389# Filter them out.3390 committer =read_git_output(['log','--no-walk','--format=%cN',3391 revision.rev.sha1])3392if committer =='Gerrit Code Review':3393return[]3394else:3395returnsuper(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision)33963397defget_update_method(self):3398return self.__update_method339934003401classGerritEnvironmentLowPrecMixin(Environment):3402def__init__(self, project=None, submitter=None, **kw):3403super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)3404 self.__project = project3405 self.__submitter = submitter34063407defget_repo_shortname(self):3408return self.__project34093410defget_fromaddr(self, change=None):3411if self.__submitter and self.__submitter.find('<') != -1:3412return self.__submitter3413else:3414returnsuper(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change)341534163417classPush(object):3418"""Represent an entire push (i.e., a group of ReferenceChanges).34193420 It is easy to figure out what commits were added to a *branch* by3421 a Reference change:34223423 git rev-list change.old..change.new34243425 or removed from a *branch*:34263427 git rev-list change.new..change.old34283429 But it is not quite so trivial to determine which entirely new3430 commits were added to the *repository* by a push and which old3431 commits were discarded by a push. A big part of the job of this3432 class is to figure out these things, and to make sure that new3433 commits are only detailed once even if they were added to multiple3434 references.34353436 The first step is to determine the "other" references--those3437 unaffected by the current push. They are computed by listing all3438 references then removing any affected by this push. The results3439 are stored in Push._other_ref_sha1s.34403441 The commits contained in the repository before this push were34423443 git rev-list other1 other2 other3 ... change1.old change2.old ...34443445 Where "changeN.old" is the old value of one of the references3446 affected by this push.34473448 The commits contained in the repository after this push are34493450 git rev-list other1 other2 other3 ... change1.new change2.new ...34513452 The commits added by this push are the difference between these3453 two sets, which can be written34543455 git rev-list \3456 ^other1 ^other2 ... \3457 ^change1.old ^change2.old ... \3458 change1.new change2.new ...34593460 The commits removed by this push can be computed by34613462 git rev-list \3463 ^other1 ^other2 ... \3464 ^change1.new ^change2.new ... \3465 change1.old change2.old ...34663467 The last point is that it is possible that other pushes are3468 occurring simultaneously to this one, so reference values can3469 change at any time. It is impossible to eliminate all race3470 conditions, but we reduce the window of time during which problems3471 can occur by translating reference names to SHA1s as soon as3472 possible and working with SHA1s thereafter (because SHA1s are3473 immutable)."""34743475# A map {(changeclass, changetype): integer} specifying the order3476# that reference changes will be processed if multiple reference3477# changes are included in a single push. The order is significant3478# mostly because new commit notifications are threaded together3479# with the first reference change that includes the commit. The3480# following order thus causes commits to be grouped with branch3481# changes (as opposed to tag changes) if possible.3482 SORT_ORDER =dict(3483(value, i)for(i, value)inenumerate([3484(BranchChange,'update'),3485(BranchChange,'create'),3486(AnnotatedTagChange,'update'),3487(AnnotatedTagChange,'create'),3488(NonAnnotatedTagChange,'update'),3489(NonAnnotatedTagChange,'create'),3490(BranchChange,'delete'),3491(AnnotatedTagChange,'delete'),3492(NonAnnotatedTagChange,'delete'),3493(OtherReferenceChange,'update'),3494(OtherReferenceChange,'create'),3495(OtherReferenceChange,'delete'),3496])3497)34983499def__init__(self, environment, changes, ignore_other_refs=False):3500 self.changes =sorted(changes, key=self._sort_key)3501 self.__other_ref_sha1s =None3502 self.__cached_commits_spec = {}3503 self.environment = environment35043505if ignore_other_refs:3506 self.__other_ref_sha1s =set()35073508@classmethod3509def_sort_key(klass, change):3510return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)35113512@property3513def_other_ref_sha1s(self):3514"""The GitObjects referred to by references unaffected by this push.3515 """3516if self.__other_ref_sha1s is None:3517# The refnames being changed by this push:3518 updated_refs =set(3519 change.refname3520for change in self.changes3521)35223523# The SHA-1s of commits referred to by all references in this3524# repository *except* updated_refs:3525 sha1s =set()3526 fmt = (3527'%(objectname) %(objecttype) %(refname)\n'3528'%(*objectname) %(*objecttype)%(refname)'3529)3530 ref_filter_regex, is_inclusion_filter = \3531 self.environment.get_ref_filter_regex()3532for line inread_git_lines(3533['for-each-ref','--format=%s'% (fmt,)]):3534(sha1,type, name) = line.split(' ',2)3535if(sha1 andtype=='commit'and3536 name not in updated_refs and3537include_ref(name, ref_filter_regex, is_inclusion_filter)):3538 sha1s.add(sha1)35393540 self.__other_ref_sha1s = sha1s35413542return self.__other_ref_sha1s35433544def_get_commits_spec_incl(self, new_or_old, reference_change=None):3545"""Get new or old SHA-1 from one or each of the changed refs.35463547 Return a list of SHA-1 commit identifier strings suitable as3548 arguments to 'git rev-list' (or 'git log' or ...). The3549 returned identifiers are either the old or new values from one3550 or all of the changed references, depending on the values of3551 new_or_old and reference_change.35523553 new_or_old is either the string 'new' or the string 'old'. If3554 'new', the returned SHA-1 identifiers are the new values from3555 each changed reference. If 'old', the SHA-1 identifiers are3556 the old values from each changed reference.35573558 If reference_change is specified and not None, only the new or3559 old reference from the specified reference is included in the3560 return value.35613562 This function returns None if there are no matching revisions3563 (e.g., because a branch was deleted and new_or_old is 'new').3564 """35653566if not reference_change:3567 incl_spec =sorted(3568getattr(change, new_or_old).sha13569for change in self.changes3570ifgetattr(change, new_or_old)3571)3572if not incl_spec:3573 incl_spec =None3574elif notgetattr(reference_change, new_or_old).commit_sha1:3575 incl_spec =None3576else:3577 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]3578return incl_spec35793580def_get_commits_spec_excl(self, new_or_old):3581"""Get exclusion revisions for determining new or discarded commits.35823583 Return a list of strings suitable as arguments to 'git3584 rev-list' (or 'git log' or ...) that will exclude all3585 commits that, depending on the value of new_or_old, were3586 either previously in the repository (useful for determining3587 which commits are new to the repository) or currently in the3588 repository (useful for determining which commits were3589 discarded from the repository).35903591 new_or_old is either the string 'new' or the string 'old'. If3592 'new', the commits to be excluded are those that were in the3593 repository before the push. If 'old', the commits to be3594 excluded are those that are currently in the repository. """35953596 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]3597 excl_revs = self._other_ref_sha1s.union(3598getattr(change, old_or_new).sha13599for change in self.changes3600ifgetattr(change, old_or_new).typein['commit','tag']3601)3602return['^'+ sha1 for sha1 insorted(excl_revs)]36033604defget_commits_spec(self, new_or_old, reference_change=None):3605"""Get rev-list arguments for added or discarded commits.36063607 Return a list of strings suitable as arguments to 'git3608 rev-list' (or 'git log' or ...) that select those commits3609 that, depending on the value of new_or_old, are either new to3610 the repository or were discarded from the repository.36113612 new_or_old is either the string 'new' or the string 'old'. If3613 'new', the returned list is used to select commits that are3614 new to the repository. If 'old', the returned value is used3615 to select the commits that have been discarded from the3616 repository.36173618 If reference_change is specified and not None, the new or3619 discarded commits are limited to those that are reachable from3620 the new or old value of the specified reference.36213622 This function returns None if there are no added (or discarded)3623 revisions.3624 """3625 key = (new_or_old, reference_change)3626if key not in self.__cached_commits_spec:3627 ret = self._get_commits_spec_incl(new_or_old, reference_change)3628if ret is not None:3629 ret.extend(self._get_commits_spec_excl(new_or_old))3630 self.__cached_commits_spec[key] = ret3631return self.__cached_commits_spec[key]36323633defget_new_commits(self, reference_change=None):3634"""Return a list of commits added by this push.36353636 Return a list of the object names of commits that were added3637 by the part of this push represented by reference_change. If3638 reference_change is None, then return a list of *all* commits3639 added by this push."""36403641 spec = self.get_commits_spec('new', reference_change)3642returngit_rev_list(spec)36433644defget_discarded_commits(self, reference_change):3645"""Return a list of commits discarded by this push.36463647 Return a list of the object names of commits that were3648 entirely discarded from the repository by the part of this3649 push represented by reference_change."""36503651 spec = self.get_commits_spec('old', reference_change)3652returngit_rev_list(spec)36533654defsend_emails(self, mailer, body_filter=None):3655"""Use send all of the notification emails needed for this push.36563657 Use send all of the notification emails (including reference3658 change emails and commit emails) needed for this push. Send3659 the emails using mailer. If body_filter is not None, then use3660 it to filter the lines that are intended for the email3661 body."""36623663# The sha1s of commits that were introduced by this push.3664# They will be removed from this set as they are processed, to3665# guarantee that one (and only one) email is generated for3666# each new commit.3667 unhandled_sha1s =set(self.get_new_commits())3668 send_date =IncrementalDateTime()3669for change in self.changes:3670 sha1s = []3671for sha1 inreversed(list(self.get_new_commits(change))):3672if sha1 in unhandled_sha1s:3673 sha1s.append(sha1)3674 unhandled_sha1s.remove(sha1)36753676# Check if we've got anyone to send to3677if not change.recipients:3678 change.environment.log_warning(3679'*** no recipients configured so no email will be sent\n'3680'*** for%rupdate%s->%s'3681% (change.refname, change.old.sha1, change.new.sha1,)3682)3683else:3684if not change.environment.quiet:3685 change.environment.log_msg(3686'Sending notification emails to:%s'% (change.recipients,))3687 extra_values = {'send_date': next(send_date)}36883689 rev = change.send_single_combined_email(sha1s)3690if rev:3691 mailer.send(3692 change.generate_combined_email(self, rev, body_filter, extra_values),3693 rev.recipients,3694)3695# This change is now fully handled; no need to handle3696# individual revisions any further.3697continue3698else:3699 mailer.send(3700 change.generate_email(self, body_filter, extra_values),3701 change.recipients,3702)37033704 max_emails = change.environment.maxcommitemails3705if max_emails andlen(sha1s) > max_emails:3706 change.environment.log_warning(3707'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s) +3708'*** Try setting multimailhook.maxCommitEmails to a greater value\n'+3709'*** Currently, multimailhook.maxCommitEmails=%d'% max_emails3710)3711return37123713for(num, sha1)inenumerate(sha1s):3714 rev =Revision(change,GitObject(sha1), num=num +1, tot=len(sha1s))3715iflen(rev.parents) >1and change.environment.excludemergerevisions:3716# skipping a merge commit3717continue3718if not rev.recipients and rev.cc_recipients:3719 change.environment.log_msg('*** Replacing Cc: with To:')3720 rev.recipients = rev.cc_recipients3721 rev.cc_recipients =None3722if rev.recipients:3723 extra_values = {'send_date': next(send_date)}3724 mailer.send(3725 rev.generate_email(self, body_filter, extra_values),3726 rev.recipients,3727)37283729# Consistency check:3730if unhandled_sha1s:3731 change.environment.log_error(3732'ERROR: No emails were sent for the following new commits:\n'3733'%s'3734% ('\n'.join(sorted(unhandled_sha1s)),)3735)373637373738definclude_ref(refname, ref_filter_regex, is_inclusion_filter):3739 does_match =bool(ref_filter_regex.search(refname))3740if is_inclusion_filter:3741return does_match3742else:# exclusion filter -- we include the ref if the regex doesn't match3743return not does_match374437453746defrun_as_post_receive_hook(environment, mailer):3747 environment.check()3748 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)3749 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)3750 changes = []3751while True:3752 line =read_line(sys.stdin)3753if line =='':3754break3755(oldrev, newrev, refname) = line.strip().split(' ',2)3756 environment.get_logger().debug(3757"run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s"%3758(oldrev, newrev, refname))37593760if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3761continue3762if notinclude_ref(refname, send_filter_regex, send_is_inclusion_filter):3763continue3764 changes.append(3765 ReferenceChange.create(environment, oldrev, newrev, refname)3766)3767if not changes:3768 mailer.close()3769return3770 push =Push(environment, changes)3771try:3772 push.send_emails(mailer, body_filter=environment.filter_body)3773finally:3774 mailer.close()377537763777defrun_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):3778 environment.check()3779 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)3780 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)3781if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3782return3783if notinclude_ref(refname, send_filter_regex, send_is_inclusion_filter):3784return3785 changes = [3786 ReferenceChange.create(3787 environment,3788read_git_output(['rev-parse','--verify', oldrev]),3789read_git_output(['rev-parse','--verify', newrev]),3790 refname,3791),3792]3793if not changes:3794 mailer.close()3795return3796 push =Push(environment, changes, force_send)3797try:3798 push.send_emails(mailer, body_filter=environment.filter_body)3799finally:3800 mailer.close()380138023803defcheck_ref_filter(environment):3804 send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True)3805 ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False)38063807definc_exc_lusion(b):3808if b:3809return'inclusion'3810else:3811return'exclusion'38123813if send_filter_regex:3814 sys.stdout.write("DoSend/DontSend filter regex ("+3815(inc_exc_lusion(send_is_inclusion)) +3816'): '+ send_filter_regex.pattern +3817'\n')3818if send_filter_regex:3819 sys.stdout.write("Include/Exclude filter regex ("+3820(inc_exc_lusion(ref_is_inclusion)) +3821'): '+ ref_filter_regex.pattern +3822'\n')3823 sys.stdout.write(os.linesep)38243825 sys.stdout.write(3826"Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"3827"or refFilterExclusionRegex. No emails will be sent for commits included\n"3828"in these refs.\n"3829"Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"3830"refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"3831"refFilterExclusionRegex. Emails will be sent for commits included in these\n"3832"refs only when the commit reaches a ref which isn't excluded.\n"3833"Refs marked as DO-SEND are not excluded by any filter. Emails will\n"3834"be sent normally for commits included in these refs.\n")38353836 sys.stdout.write(os.linesep)38373838for refname inread_git_lines(['for-each-ref','--format','%(refname)']):3839 sys.stdout.write(refname)3840if notinclude_ref(refname, ref_filter_regex, ref_is_inclusion):3841 sys.stdout.write(' EXCLUDE')3842elif notinclude_ref(refname, send_filter_regex, send_is_inclusion):3843 sys.stdout.write(' DONT-SEND')3844else:3845 sys.stdout.write(' DO-SEND')38463847 sys.stdout.write(os.linesep)384838493850defshow_env(environment, out):3851 out.write('Environment values:\n')3852for(k, v)insorted(environment.get_values().items()):3853if k:# Don't show the {'' : ''} pair.3854 out.write('%s:%r\n'% (k, v))3855 out.write('\n')3856# Flush to avoid interleaving with further log output3857 out.flush()385838593860defcheck_setup(environment):3861 environment.check()3862show_env(environment, sys.stdout)3863 sys.stdout.write("Now, checking that git-multimail's standard input "3864"is properly set ..."+ os.linesep)3865 sys.stdout.write("Please type some text and then press Return"+ os.linesep)3866 stdin = sys.stdin.readline()3867 sys.stdout.write("You have just entered:"+ os.linesep)3868 sys.stdout.write(stdin)3869 sys.stdout.write("git-multimail seems properly set up."+ os.linesep)387038713872defchoose_mailer(config, environment):3873 mailer = config.get('mailer', default='sendmail')38743875if mailer =='smtp':3876 smtpserver = config.get('smtpserver', default='localhost')3877 smtpservertimeout =float(config.get('smtpservertimeout', default=10.0))3878 smtpserverdebuglevel =int(config.get('smtpserverdebuglevel', default=0))3879 smtpencryption = config.get('smtpencryption', default='none')3880 smtpuser = config.get('smtpuser', default='')3881 smtppass = config.get('smtppass', default='')3882 smtpcacerts = config.get('smtpcacerts', default='')3883 mailer =SMTPMailer(3884 environment,3885 envelopesender=(environment.get_sender()or environment.get_fromaddr()),3886 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,3887 smtpserverdebuglevel=smtpserverdebuglevel,3888 smtpencryption=smtpencryption,3889 smtpuser=smtpuser,3890 smtppass=smtppass,3891 smtpcacerts=smtpcacerts3892)3893elif mailer =='sendmail':3894 command = config.get('sendmailcommand')3895if command:3896 command = shlex.split(command)3897 mailer =SendMailer(environment,3898 command=command, envelopesender=environment.get_sender())3899else:3900 environment.log_error(3901'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer +3902'please use one of "smtp" or "sendmail".'3903)3904 sys.exit(1)3905return mailer390639073908KNOWN_ENVIRONMENTS = {3909'generic': {'highprec': GenericEnvironmentMixin},3910'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin,3911'lowprec': GitoliteEnvironmentLowPrecMixin},3912'stash': {'highprec': StashEnvironmentHighPrecMixin,3913'lowprec': StashEnvironmentLowPrecMixin},3914'gerrit': {'highprec': GerritEnvironmentHighPrecMixin,3915'lowprec': GerritEnvironmentLowPrecMixin},3916}391739183919defchoose_environment(config, osenv=None, env=None, recipients=None,3920 hook_info=None):3921 env_name =choose_environment_name(config, env, osenv)3922 environment_klass =build_environment_klass(env_name)3923 env =build_environment(environment_klass, env_name, config,3924 osenv, recipients, hook_info)3925return env392639273928defchoose_environment_name(config, env, osenv):3929if not osenv:3930 osenv = os.environ39313932if not env:3933 env = config.get('environment')39343935if not env:3936if'GL_USER'in osenv and'GL_REPO'in osenv:3937 env ='gitolite'3938else:3939 env ='generic'3940return env394139423943COMMON_ENVIRONMENT_MIXINS = [3944 ConfigRecipientsEnvironmentMixin,3945 CLIRecipientsEnvironmentMixin,3946 ConfigRefFilterEnvironmentMixin,3947 ProjectdescEnvironmentMixin,3948 ConfigMaxlinesEnvironmentMixin,3949 ComputeFQDNEnvironmentMixin,3950 ConfigFilterLinesEnvironmentMixin,3951 PusherDomainEnvironmentMixin,3952 ConfigOptionsEnvironmentMixin,3953]395439553956defbuild_environment_klass(env_name):3957if'class'in KNOWN_ENVIRONMENTS[env_name]:3958return KNOWN_ENVIRONMENTS[env_name]['class']39593960 environment_mixins = []3961 known_env = KNOWN_ENVIRONMENTS[env_name]3962if'highprec'in known_env:3963 high_prec_mixin = known_env['highprec']3964 environment_mixins.append(high_prec_mixin)3965 environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS3966if'lowprec'in known_env:3967 low_prec_mixin = known_env['lowprec']3968 environment_mixins.append(low_prec_mixin)3969 environment_mixins.append(Environment)3970 klass_name = env_name.capitalize() +'Environment'3971 environment_klass =type(3972 klass_name,3973tuple(environment_mixins),3974{},3975)3976 KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass3977return environment_klass397839793980GerritEnvironment =build_environment_klass('gerrit')3981StashEnvironment =build_environment_klass('stash')3982GitoliteEnvironment =build_environment_klass('gitolite')3983GenericEnvironment =build_environment_klass('generic')398439853986defbuild_environment(environment_klass, env, config,3987 osenv, recipients, hook_info):3988 environment_kw = {3989'osenv': osenv,3990'config': config,3991}39923993if env =='stash':3994 environment_kw['user'] = hook_info['stash_user']3995 environment_kw['repo'] = hook_info['stash_repo']3996elif env =='gerrit':3997 environment_kw['project'] = hook_info['project']3998 environment_kw['submitter'] = hook_info['submitter']3999 environment_kw['update_method'] = hook_info['update_method']40004001 environment_kw['cli_recipients'] = recipients40024003returnenvironment_klass(**environment_kw)400440054006defget_version():4007 oldcwd = os.getcwd()4008try:4009try:4010 os.chdir(os.path.dirname(os.path.realpath(__file__)))4011 git_version =read_git_output(['describe','--tags','HEAD'])4012if git_version == __version__:4013return git_version4014else:4015return'%s(%s)'% (__version__, git_version)4016except:4017pass4018finally:4019 os.chdir(oldcwd)4020return __version__402140224023defcompute_gerrit_options(options, args, required_gerrit_options,4024 raw_refname):4025if None in required_gerrit_options:4026raiseSystemExit("Error: Specify all of --oldrev, --newrev, --refname, "4027"and --project; or none of them.")40284029if options.environment not in(None,'gerrit'):4030raiseSystemExit("Non-gerrit environments incompatible with --oldrev, "4031"--newrev, --refname, and --project")4032 options.environment ='gerrit'40334034if args:4035raiseSystemExit("Error: Positional parameters not allowed with "4036"--oldrev, --newrev, and --refname.")40374038# Gerrit oddly omits 'refs/heads/' in the refname when calling4039# ref-updated hook; put it back.4040 git_dir =get_git_dir()4041if(not os.path.exists(os.path.join(git_dir, raw_refname))and4042 os.path.exists(os.path.join(git_dir,'refs','heads',4043 raw_refname))):4044 options.refname ='refs/heads/'+ options.refname40454046# New revisions can appear in a gerrit repository either due to someone4047# pushing directly (in which case options.submitter will be set), or they4048# can press "Submit this patchset" in the web UI for some CR (in which4049# case options.submitter will not be set and gerrit will not have provided4050# us the information about who pressed the button).4051#4052# Note for the nit-picky: I'm lumping in REST API calls and the ssh4053# gerrit review command in with "Submit this patchset" button, since they4054# have the same effect.4055if options.submitter:4056 update_method ='pushed'4057# The submitter argument is almost an RFC 2822 email address; change it4058# from 'User Name (email@domain)' to 'User Name <email@domain>' so it is4059 options.submitter = options.submitter.replace('(','<').replace(')','>')4060else:4061 update_method ='submitted'4062# Gerrit knew who submitted this patchset, but threw that information4063# away when it invoked this hook. However, *IF* Gerrit created a4064# merge to bring the patchset in (project 'Submit Type' is either4065# "Always Merge", or is "Merge if Necessary" and happens to be4066# necessary for this particular CR), then it will have the committer4067# of that merge be 'Gerrit Code Review' and the author will be the4068# person who requested the submission of the CR. Since this is fairly4069# likely for most gerrit installations (of a reasonable size), it's4070# worth the extra effort to try to determine the actual submitter.4071 rev_info =read_git_lines(['log','--no-walk','--merges',4072'--format=%cN%n%aN <%aE>', options.newrev])4073if rev_info and rev_info[0] =='Gerrit Code Review':4074 options.submitter = rev_info[1]40754076# We pass back refname, oldrev, newrev as args because then the4077# gerrit ref-updated hook is much like the git update hook4078return(options,4079[options.refname, options.oldrev, options.newrev],4080{'project': options.project,'submitter': options.submitter,4081'update_method': update_method})408240834084defcheck_hook_specific_args(options, args):4085 raw_refname = options.refname4086# Convert each string option unicode for Python3.4087if PYTHON3:4088 opts = ['environment','recipients','oldrev','newrev','refname',4089'project','submitter','stash_user','stash_repo']4090for opt in opts:4091if nothasattr(options, opt):4092continue4093 obj =getattr(options, opt)4094if obj:4095 enc = obj.encode('utf-8','surrogateescape')4096 dec = enc.decode('utf-8','replace')4097setattr(options, opt, dec)40984099# First check for stash arguments4100if(options.stash_user is None) != (options.stash_repo is None):4101raiseSystemExit("Error: Specify both of --stash-user and "4102"--stash-repo or neither.")4103if options.stash_user:4104 options.environment ='stash'4105return options, args, {'stash_user': options.stash_user,4106'stash_repo': options.stash_repo}41074108# Finally, check for gerrit specific arguments4109 required_gerrit_options = (options.oldrev, options.newrev, options.refname,4110 options.project)4111if required_gerrit_options != (None,) *4:4112returncompute_gerrit_options(options, args, required_gerrit_options,4113 raw_refname)41144115# No special options in use, just return what we started with4116return options, args, {}411741184119classLogger(object):4120defparse_verbose(self, verbose):4121if verbose >0:4122return logging.DEBUG4123else:4124return logging.INFO41254126defcreate_log_file(self, environment, name, path, verbosity):4127 log_file = logging.getLogger(name)4128 file_handler = logging.FileHandler(path)4129 log_fmt = logging.Formatter("%(asctime)s[%(levelname)-5.5s]%(message)s")4130 file_handler.setFormatter(log_fmt)4131 log_file.addHandler(file_handler)4132 log_file.setLevel(verbosity)4133return log_file41344135def__init__(self, environment):4136 self.environment = environment4137 self.loggers = []4138 stderr_log = logging.getLogger('git_multimail.stderr')41394140classEncodedStderr(object):4141defwrite(self, x):4142write_str(sys.stderr, x)41434144defflush(self):4145 sys.stderr.flush()41464147 stderr_handler = logging.StreamHandler(EncodedStderr())4148 stderr_log.addHandler(stderr_handler)4149 stderr_log.setLevel(self.parse_verbose(environment.verbose))4150 self.loggers.append(stderr_log)41514152if environment.debug_log_file is not None:4153 debug_log_file = self.create_log_file(4154 environment,'git_multimail.debug', environment.debug_log_file, logging.DEBUG)4155 self.loggers.append(debug_log_file)41564157if environment.log_file is not None:4158 log_file = self.create_log_file(4159 environment,'git_multimail.file', environment.log_file, logging.INFO)4160 self.loggers.append(log_file)41614162if environment.error_log_file is not None:4163 error_log_file = self.create_log_file(4164 environment,'git_multimail.error', environment.error_log_file, logging.ERROR)4165 self.loggers.append(error_log_file)41664167definfo(self, msg, *args, **kwargs):4168for l in self.loggers:4169 l.info(msg, *args, **kwargs)41704171defdebug(self, msg, *args, **kwargs):4172for l in self.loggers:4173 l.debug(msg, *args, **kwargs)41744175defwarning(self, msg, *args, **kwargs):4176for l in self.loggers:4177 l.warning(msg, *args, **kwargs)41784179deferror(self, msg, *args, **kwargs):4180for l in self.loggers:4181 l.error(msg, *args, **kwargs)418241834184defmain(args):4185 parser = optparse.OptionParser(4186 description=__doc__,4187 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',4188)41894190 parser.add_option(4191'--environment','--env', action='store',type='choice',4192 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,4193help=(4194'Choose type of environment is in use. Default is taken from '4195'multimailhook.environment if set; otherwise "generic".'4196),4197)4198 parser.add_option(4199'--stdout', action='store_true', default=False,4200help='Output emails to stdout rather than sending them.',4201)4202 parser.add_option(4203'--recipients', action='store', default=None,4204help='Set list of email recipients for all types of emails.',4205)4206 parser.add_option(4207'--show-env', action='store_true', default=False,4208help=(4209'Write to stderr the values determined for the environment '4210'(intended for debugging purposes), then proceed normally.'4211),4212)4213 parser.add_option(4214'--force-send', action='store_true', default=False,4215help=(4216'Force sending refchange email when using as an update hook. '4217'This is useful to work around the unreliable new commits '4218'detection in this mode.'4219),4220)4221 parser.add_option(4222'-c', metavar="<name>=<value>", action='append',4223help=(4224'Pass a configuration parameter through to git. The value given '4225'will override values from configuration files. See the -c option '4226'of git(1) for more details. (Only works with git >= 1.7.3)'4227),4228)4229 parser.add_option(4230'--version','-v', action='store_true', default=False,4231help=(4232"Display git-multimail's version"4233),4234)42354236 parser.add_option(4237'--python-version', action='store_true', default=False,4238help=(4239"Display the version of Python used by git-multimail"4240),4241)42424243 parser.add_option(4244'--check-ref-filter', action='store_true', default=False,4245help=(4246'List refs and show information on how git-multimail '4247'will process them.'4248)4249)42504251# The following options permit this script to be run as a gerrit4252# ref-updated hook. See e.g.4253# code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt4254# We suppress help for these items, since these are specific to gerrit,4255# and we don't want users directly using them any way other than how the4256# gerrit ref-updated hook is called.4257 parser.add_option('--oldrev', action='store',help=optparse.SUPPRESS_HELP)4258 parser.add_option('--newrev', action='store',help=optparse.SUPPRESS_HELP)4259 parser.add_option('--refname', action='store',help=optparse.SUPPRESS_HELP)4260 parser.add_option('--project', action='store',help=optparse.SUPPRESS_HELP)4261 parser.add_option('--submitter', action='store',help=optparse.SUPPRESS_HELP)42624263# The following allow this to be run as a stash asynchronous post-receive4264# hook (almost identical to a git post-receive hook but triggered also for4265# merges of pull requests from the UI). We suppress help for these items,4266# since these are specific to stash.4267 parser.add_option('--stash-user', action='store',help=optparse.SUPPRESS_HELP)4268 parser.add_option('--stash-repo', action='store',help=optparse.SUPPRESS_HELP)42694270(options, args) = parser.parse_args(args)4271(options, args, hook_info) =check_hook_specific_args(options, args)42724273if options.version:4274 sys.stdout.write('git-multimail version '+get_version() +'\n')4275return42764277if options.python_version:4278 sys.stdout.write('Python version '+ sys.version +'\n')4279return42804281if options.c:4282 Config.add_config_parameters(options.c)42834284 config =Config('multimailhook')42854286 environment =None4287try:4288 environment =choose_environment(4289 config, osenv=os.environ,4290 env=options.environment,4291 recipients=options.recipients,4292 hook_info=hook_info,4293)42944295if options.show_env:4296show_env(environment, sys.stderr)42974298if options.stdout or environment.stdout:4299 mailer =OutputMailer(sys.stdout, environment)4300else:4301 mailer =choose_mailer(config, environment)43024303 must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP')4304if must_check_setup =='':4305 must_check_setup =False4306if options.check_ref_filter:4307check_ref_filter(environment)4308elif must_check_setup:4309check_setup(environment)4310# Dual mode: if arguments were specified on the command line, run4311# like an update hook; otherwise, run as a post-receive hook.4312elif args:4313iflen(args) !=3:4314 parser.error('Need zero or three non-option arguments')4315(refname, oldrev, newrev) = args4316 environment.get_logger().debug(4317"run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s"%4318(refname, oldrev, newrev, options.force_send))4319run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)4320else:4321run_as_post_receive_hook(environment, mailer)4322except ConfigurationException:4323 sys.exit(sys.exc_info()[1])4324exceptSystemExit:4325raise4326exceptException:4327 t, e, tb = sys.exc_info()4328import traceback4329 sys.stderr.write('\n')# Avoid mixing message with previous output4330 msg = (4331'Exception\''+ t.__name__+4332'\'raised. Please report this as a bug to\n'4333'https://github.com/git-multimail/git-multimail/issues\n'4334'with the information below:\n\n'4335'git-multimail version '+get_version() +'\n'4336'Python version '+ sys.version +'\n'+4337 traceback.format_exc())4338try:4339 environment.get_logger().error(msg)4340except:4341 sys.stderr.write(msg)4342 sys.exit(1)434343444345if __name__ =='__main__':4346main(sys.argv[1:])