1#! /usr/bin/env python 2 3__version__ ='1.4.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 67import cgi 68 69PYTHON3 = sys.version_info >= (3,0) 70 71if sys.version_info <= (2,5): 72defall(iterable): 73for element in iterable: 74if not element: 75return False 76return True 77 78 79defis_ascii(s): 80returnall(ord(c) <128andord(c) >0for c in s) 81 82 83if PYTHON3: 84defis_string(s): 85returnisinstance(s,str) 86 87defstr_to_bytes(s): 88return s.encode(ENCODING) 89 90defbytes_to_str(s, errors='strict'): 91return s.decode(ENCODING, errors) 92 93unicode=str 94 95defwrite_str(f, msg): 96# Try outputing with the default encoding. If it fails, 97# try UTF-8. 98try: 99 f.buffer.write(msg.encode(sys.getdefaultencoding())) 100exceptUnicodeEncodeError: 101 f.buffer.write(msg.encode(ENCODING)) 102 103defread_line(f): 104# Try reading with the default encoding. If it fails, 105# try UTF-8. 106 out = f.buffer.readline() 107try: 108return out.decode(sys.getdefaultencoding()) 109exceptUnicodeEncodeError: 110return out.decode(ENCODING) 111else: 112defis_string(s): 113try: 114returnisinstance(s, basestring) 115exceptNameError:# Silence Pyflakes warning 116raise 117 118defstr_to_bytes(s): 119return s 120 121defbytes_to_str(s, errors='strict'): 122return s 123 124defwrite_str(f, msg): 125 f.write(msg) 126 127defread_line(f): 128return f.readline() 129 130defnext(it): 131return it.next() 132 133 134try: 135from email.charset import Charset 136from email.utils import make_msgid 137from email.utils import getaddresses 138from email.utils import formataddr 139from email.utils import formatdate 140from email.header import Header 141exceptImportError: 142# Prior to Python 2.5, the email module used different names: 143from email.Charset import Charset 144from email.Utils import make_msgid 145from email.Utils import getaddresses 146from email.Utils import formataddr 147from email.Utils import formatdate 148from email.Header import Header 149 150 151DEBUG =False 152 153ZEROS ='0'*40 154LOGBEGIN ='- Log -----------------------------------------------------------------\n' 155LOGEND ='-----------------------------------------------------------------------\n' 156 157ADDR_HEADERS =set(['from','to','cc','bcc','reply-to','sender']) 158 159# It is assumed in many places that the encoding is uniformly UTF-8, 160# so changing these constants is unsupported. But define them here 161# anyway, to make it easier to find (at least most of) the places 162# where the encoding is important. 163(ENCODING, CHARSET) = ('UTF-8','utf-8') 164 165 166REF_CREATED_SUBJECT_TEMPLATE = ( 167'%(emailprefix)s%(refname_type)s %(short_refname)screated' 168' (now%(newrev_short)s)' 169) 170REF_UPDATED_SUBJECT_TEMPLATE = ( 171'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 172' (%(oldrev_short)s->%(newrev_short)s)' 173) 174REF_DELETED_SUBJECT_TEMPLATE = ( 175'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 176' (was%(oldrev_short)s)' 177) 178 179COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( 180'%(emailprefix)s%(refname_type)s %(short_refname)supdated:%(oneline)s' 181) 182 183REFCHANGE_HEADER_TEMPLATE ="""\ 184Date:%(send_date)s 185To:%(recipients)s 186Subject:%(subject)s 187MIME-Version: 1.0 188Content-Type: text/%(contenttype)s; charset=%(charset)s 189Content-Transfer-Encoding: 8bit 190Message-ID:%(msgid)s 191From:%(fromaddr)s 192Reply-To:%(reply_to)s 193X-Git-Host:%(fqdn)s 194X-Git-Repo:%(repo_shortname)s 195X-Git-Refname:%(refname)s 196X-Git-Reftype:%(refname_type)s 197X-Git-Oldrev:%(oldrev)s 198X-Git-Newrev:%(newrev)s 199X-Git-NotificationType: ref_changed 200X-Git-Multimail-Version:%(multimail_version)s 201Auto-Submitted: auto-generated 202""" 203 204REFCHANGE_INTRO_TEMPLATE ="""\ 205This is an automated email from the git hooks/post-receive script. 206 207%(pusher)spushed a change to%(refname_type)s %(short_refname)s 208in repository%(repo_shortname)s. 209 210""" 211 212 213FOOTER_TEMPLATE ="""\ 214 215--\n\ 216To stop receiving notification emails like this one, please contact 217%(administrator)s. 218""" 219 220 221REWIND_ONLY_TEMPLATE ="""\ 222This update removed existing revisions from the reference, leaving the 223reference pointing at a previous point in the repository history. 224 225 * -- * -- N%(refname)s(%(newrev_short)s) 226\\ 227 O -- O -- O (%(oldrev_short)s) 228 229Any revisions marked "omit" are not gone; other references still 230refer to them. Any revisions marked "discard" are gone forever. 231""" 232 233 234NON_FF_TEMPLATE ="""\ 235This update added new revisions after undoing existing revisions. 236That is to say, some revisions that were in the old version of the 237%(refname_type)sare not in the new version. This situation occurs 238when a user --force pushes a change and generates a repository 239containing something like this: 240 241 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 242\\ 243 N -- N -- N%(refname)s(%(newrev_short)s) 244 245You should already have received notification emails for all of the O 246revisions, and so the following emails describe only the N revisions 247from the common base, B. 248 249Any revisions marked "omit" are not gone; other references still 250refer to them. Any revisions marked "discard" are gone forever. 251""" 252 253 254NO_NEW_REVISIONS_TEMPLATE ="""\ 255No new revisions were added by this update. 256""" 257 258 259DISCARDED_REVISIONS_TEMPLATE ="""\ 260This change permanently discards the following revisions: 261""" 262 263 264NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 265The revisions that were on this%(refname_type)sare still contained in 266other references; therefore, this change does not discard any commits 267from the repository. 268""" 269 270 271NEW_REVISIONS_TEMPLATE ="""\ 272The%(tot)srevisions listed above as "new" are entirely new to this 273repository and will be described in separate emails. The revisions 274listed as "add" were already present in the repository and have only 275been added to this reference. 276 277""" 278 279 280TAG_CREATED_TEMPLATE ="""\ 281 at%(newrev_short)-8s (%(newrev_type)s) 282""" 283 284 285TAG_UPDATED_TEMPLATE ="""\ 286*** WARNING: tag%(short_refname)swas modified! *** 287 288 from%(oldrev_short)-8s (%(oldrev_type)s) 289 to%(newrev_short)-8s (%(newrev_type)s) 290""" 291 292 293TAG_DELETED_TEMPLATE ="""\ 294*** WARNING: tag%(short_refname)swas deleted! *** 295 296""" 297 298 299# The template used in summary tables. It looks best if this uses the 300# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 301BRIEF_SUMMARY_TEMPLATE ="""\ 302%(action)8s%(rev_short)-8s%(text)s 303""" 304 305 306NON_COMMIT_UPDATE_TEMPLATE ="""\ 307This is an unusual reference change because the reference did not 308refer to a commit either before or after the change. We do not know 309how to provide full information about this reference change. 310""" 311 312 313REVISION_HEADER_TEMPLATE ="""\ 314Date:%(send_date)s 315To:%(recipients)s 316Cc:%(cc_recipients)s 317Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 318MIME-Version: 1.0 319Content-Type: text/%(contenttype)s; charset=%(charset)s 320Content-Transfer-Encoding: 8bit 321From:%(fromaddr)s 322Reply-To:%(reply_to)s 323In-Reply-To:%(reply_to_msgid)s 324References:%(reply_to_msgid)s 325X-Git-Host:%(fqdn)s 326X-Git-Repo:%(repo_shortname)s 327X-Git-Refname:%(refname)s 328X-Git-Reftype:%(refname_type)s 329X-Git-Rev:%(rev)s 330X-Git-NotificationType: diff 331X-Git-Multimail-Version:%(multimail_version)s 332Auto-Submitted: auto-generated 333""" 334 335REVISION_INTRO_TEMPLATE ="""\ 336This is an automated email from the git hooks/post-receive script. 337 338%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 339in repository%(repo_shortname)s. 340 341""" 342 343LINK_TEXT_TEMPLATE ="""\ 344View the commit online: 345%(browse_url)s 346 347""" 348 349LINK_HTML_TEMPLATE ="""\ 350<p><a href="%(browse_url)s">View the commit online</a>.</p> 351""" 352 353 354REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 355 356 357# Combined, meaning refchange+revision email (for single-commit additions) 358COMBINED_HEADER_TEMPLATE ="""\ 359Date:%(send_date)s 360To:%(recipients)s 361Subject:%(subject)s 362MIME-Version: 1.0 363Content-Type: text/%(contenttype)s; charset=%(charset)s 364Content-Transfer-Encoding: 8bit 365Message-ID:%(msgid)s 366From:%(fromaddr)s 367Reply-To:%(reply_to)s 368X-Git-Host:%(fqdn)s 369X-Git-Repo:%(repo_shortname)s 370X-Git-Refname:%(refname)s 371X-Git-Reftype:%(refname_type)s 372X-Git-Oldrev:%(oldrev)s 373X-Git-Newrev:%(newrev)s 374X-Git-Rev:%(rev)s 375X-Git-NotificationType: ref_changed_plus_diff 376X-Git-Multimail-Version:%(multimail_version)s 377Auto-Submitted: auto-generated 378""" 379 380COMBINED_INTRO_TEMPLATE ="""\ 381This is an automated email from the git hooks/post-receive script. 382 383%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 384in repository%(repo_shortname)s. 385 386""" 387 388COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE 389 390 391classCommandError(Exception): 392def__init__(self, cmd, retcode): 393 self.cmd = cmd 394 self.retcode = retcode 395Exception.__init__( 396 self, 397'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 398) 399 400 401classConfigurationException(Exception): 402pass 403 404 405# The "git" program (this could be changed to include a full path): 406GIT_EXECUTABLE ='git' 407 408 409# How "git" should be invoked (including global arguments), as a list 410# of words. This variable is usually initialized automatically by 411# read_git_output() via choose_git_command(), but if a value is set 412# here then it will be used unconditionally. 413GIT_CMD =None 414 415 416defchoose_git_command(): 417"""Decide how to invoke git, and record the choice in GIT_CMD.""" 418 419global GIT_CMD 420 421if GIT_CMD is None: 422try: 423# Check to see whether the "-c" option is accepted (it was 424# only added in Git 1.7.2). We don't actually use the 425# output of "git --version", though if we needed more 426# specific version information this would be the place to 427# do it. 428 cmd = [GIT_EXECUTABLE,'-c','foo.bar=baz','--version'] 429read_output(cmd) 430 GIT_CMD = [GIT_EXECUTABLE,'-c','i18n.logoutputencoding=%s'% (ENCODING,)] 431except CommandError: 432 GIT_CMD = [GIT_EXECUTABLE] 433 434 435defread_git_output(args,input=None, keepends=False, **kw): 436"""Read the output of a Git command.""" 437 438if GIT_CMD is None: 439choose_git_command() 440 441returnread_output(GIT_CMD + args,input=input, keepends=keepends, **kw) 442 443 444defread_output(cmd,input=None, keepends=False, **kw): 445ifinput: 446 stdin = subprocess.PIPE 447input=str_to_bytes(input) 448else: 449 stdin =None 450 errors ='strict' 451if'errors'in kw: 452 errors = kw['errors'] 453del kw['errors'] 454 p = subprocess.Popen( 455tuple(str_to_bytes(w)for w in cmd), 456 stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 457) 458(out, err) = p.communicate(input) 459 out =bytes_to_str(out, errors=errors) 460 retcode = p.wait() 461if retcode: 462raiseCommandError(cmd, retcode) 463if not keepends: 464 out = out.rstrip('\n\r') 465return out 466 467 468defread_git_lines(args, keepends=False, **kw): 469"""Return the lines output by Git command. 470 471 Return as single lines, with newlines stripped off.""" 472 473returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 474 475 476defgit_rev_list_ish(cmd, spec, args=None, **kw): 477"""Common functionality for invoking a 'git rev-list'-like command. 478 479 Parameters: 480 * cmd is the Git command to run, e.g., 'rev-list' or 'log'. 481 * spec is a list of revision arguments to pass to the named 482 command. If None, this function returns an empty list. 483 * args is a list of extra arguments passed to the named command. 484 * All other keyword arguments (if any) are passed to the 485 underlying read_git_lines() function. 486 487 Return the output of the Git command in the form of a list, one 488 entry per output line. 489 """ 490if spec is None: 491return[] 492if args is None: 493 args = [] 494 args = [cmd,'--stdin'] + args 495 spec_stdin =''.join(s +'\n'for s in spec) 496returnread_git_lines(args,input=spec_stdin, **kw) 497 498 499defgit_rev_list(spec, **kw): 500"""Run 'git rev-list' with the given list of revision arguments. 501 502 See git_rev_list_ish() for parameter and return value 503 documentation. 504 """ 505returngit_rev_list_ish('rev-list', spec, **kw) 506 507 508defgit_log(spec, **kw): 509"""Run 'git log' with the given list of revision arguments. 510 511 See git_rev_list_ish() for parameter and return value 512 documentation. 513 """ 514returngit_rev_list_ish('log', spec, **kw) 515 516 517defheader_encode(text, header_name=None): 518"""Encode and line-wrap the value of an email header field.""" 519 520# Convert to unicode, if required. 521if notisinstance(text,unicode): 522 text =unicode(text,'utf-8') 523 524ifis_ascii(text): 525 charset ='ascii' 526else: 527 charset ='utf-8' 528 529returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 530 531 532defaddr_header_encode(text, header_name=None): 533"""Encode and line-wrap the value of an email header field containing 534 email addresses.""" 535 536# Convert to unicode, if required. 537if notisinstance(text,unicode): 538 text =unicode(text,'utf-8') 539 540 text =', '.join( 541formataddr((header_encode(name), emailaddr)) 542for name, emailaddr ingetaddresses([text]) 543) 544 545ifis_ascii(text): 546 charset ='ascii' 547else: 548 charset ='utf-8' 549 550returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 551 552 553classConfig(object): 554def__init__(self, section, git_config=None): 555"""Represent a section of the git configuration. 556 557 If git_config is specified, it is passed to "git config" in 558 the GIT_CONFIG environment variable, meaning that "git config" 559 will read the specified path rather than the Git default 560 config paths.""" 561 562 self.section = section 563if git_config: 564 self.env = os.environ.copy() 565 self.env['GIT_CONFIG'] = git_config 566else: 567 self.env =None 568 569@staticmethod 570def_split(s): 571"""Split NUL-terminated values.""" 572 573 words = s.split('\0') 574assert words[-1] =='' 575return words[:-1] 576 577@staticmethod 578defadd_config_parameters(c): 579"""Add configuration parameters to Git. 580 581 c is either an str or a list of str, each element being of the 582 form 'var=val' or 'var', with the same syntax and meaning as 583 the argument of 'git -c var=val'. 584 """ 585ifisinstance(c,str): 586 c = (c,) 587 parameters = os.environ.get('GIT_CONFIG_PARAMETERS','') 588if parameters: 589 parameters +=' ' 590# git expects GIT_CONFIG_PARAMETERS to be of the form 591# "'name1=value1' 'name2=value2' 'name3=value3'" 592# including everything inside the double quotes (but not the double 593# quotes themselves). Spacing is critical. Also, if a value contains 594# a literal single quote that quote must be represented using the 595# four character sequence: '\'' 596 parameters +=' '.join("'"+ x.replace("'","'\\''") +"'"for x in c) 597 os.environ['GIT_CONFIG_PARAMETERS'] = parameters 598 599defget(self, name, default=None): 600try: 601 values = self._split(read_git_output( 602['config','--get','--null','%s.%s'% (self.section, name)], 603 env=self.env, keepends=True, 604)) 605assertlen(values) ==1 606return values[0] 607except CommandError: 608return default 609 610defget_bool(self, name, default=None): 611try: 612 value =read_git_output( 613['config','--get','--bool','%s.%s'% (self.section, name)], 614 env=self.env, 615) 616except CommandError: 617return default 618return value =='true' 619 620defget_all(self, name, default=None): 621"""Read a (possibly multivalued) setting from the configuration. 622 623 Return the result as a list of values, or default if the name 624 is unset.""" 625 626try: 627return self._split(read_git_output( 628['config','--get-all','--null','%s.%s'% (self.section, name)], 629 env=self.env, keepends=True, 630)) 631except CommandError: 632 t, e, traceback = sys.exc_info() 633if e.retcode ==1: 634# "the section or key is invalid"; i.e., there is no 635# value for the specified key. 636return default 637else: 638raise 639 640defset(self, name, value): 641read_git_output( 642['config','%s.%s'% (self.section, name), value], 643 env=self.env, 644) 645 646defadd(self, name, value): 647read_git_output( 648['config','--add','%s.%s'% (self.section, name), value], 649 env=self.env, 650) 651 652def__contains__(self, name): 653return self.get_all(name, default=None)is not None 654 655# We don't use this method anymore internally, but keep it here in 656# case somebody is calling it from their own code: 657defhas_key(self, name): 658return name in self 659 660defunset_all(self, name): 661try: 662read_git_output( 663['config','--unset-all','%s.%s'% (self.section, name)], 664 env=self.env, 665) 666except CommandError: 667 t, e, traceback = sys.exc_info() 668if e.retcode ==5: 669# The name doesn't exist, which is what we wanted anyway... 670pass 671else: 672raise 673 674defset_recipients(self, name, value): 675 self.unset_all(name) 676for pair ingetaddresses([value]): 677 self.add(name,formataddr(pair)) 678 679 680defgenerate_summaries(*log_args): 681"""Generate a brief summary for each revision requested. 682 683 log_args are strings that will be passed directly to "git log" as 684 revision selectors. Iterate over (sha1_short, subject) for each 685 commit specified by log_args (subject is the first line of the 686 commit message as a string without EOLs).""" 687 688 cmd = [ 689'log','--abbrev','--format=%h%s', 690] +list(log_args) + ['--'] 691for line inread_git_lines(cmd): 692yieldtuple(line.split(' ',1)) 693 694 695deflimit_lines(lines, max_lines): 696for(index, line)inenumerate(lines): 697if index < max_lines: 698yield line 699 700if index >= max_lines: 701yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 702 703 704deflimit_linelength(lines, max_linelength): 705for line in lines: 706# Don't forget that lines always include a trailing newline. 707iflen(line) > max_linelength +1: 708 line = line[:max_linelength -7] +' [...]\n' 709yield line 710 711 712classCommitSet(object): 713"""A (constant) set of object names. 714 715 The set should be initialized with full SHA1 object names. The 716 __contains__() method returns True iff its argument is an 717 abbreviation of any the names in the set.""" 718 719def__init__(self, names): 720 self._names =sorted(names) 721 722def__len__(self): 723returnlen(self._names) 724 725def__contains__(self, sha1_abbrev): 726"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 727 728 i = bisect.bisect_left(self._names, sha1_abbrev) 729return i <len(self)and self._names[i].startswith(sha1_abbrev) 730 731 732classGitObject(object): 733def__init__(self, sha1,type=None): 734if sha1 == ZEROS: 735 self.sha1 = self.type= self.commit_sha1 =None 736else: 737 self.sha1 = sha1 738 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 739 740if self.type=='commit': 741 self.commit_sha1 = self.sha1 742elif self.type=='tag': 743try: 744 self.commit_sha1 =read_git_output( 745['rev-parse','--verify','%s^0'% (self.sha1,)] 746) 747except CommandError: 748# Cannot deref tag to determine commit_sha1 749 self.commit_sha1 =None 750else: 751 self.commit_sha1 =None 752 753 self.short =read_git_output(['rev-parse','--short', sha1]) 754 755defget_summary(self): 756"""Return (sha1_short, subject) for this commit.""" 757 758if not self.sha1: 759raiseValueError('Empty commit has no summary') 760 761returnnext(iter(generate_summaries('--no-walk', self.sha1))) 762 763def__eq__(self, other): 764returnisinstance(other, GitObject)and self.sha1 == other.sha1 765 766def__hash__(self): 767returnhash(self.sha1) 768 769def__nonzero__(self): 770returnbool(self.sha1) 771 772def__bool__(self): 773"""Python 2 backward compatibility""" 774return self.__nonzero__() 775 776def__str__(self): 777return self.sha1 or ZEROS 778 779 780classChange(object): 781"""A Change that has been made to the Git repository. 782 783 Abstract class from which both Revisions and ReferenceChanges are 784 derived. A Change knows how to generate a notification email 785 describing itself.""" 786 787def__init__(self, environment): 788 self.environment = environment 789 self._values =None 790 self._contains_html_diff =False 791 792def_contains_diff(self): 793# We do contain a diff, should it be rendered in HTML? 794if self.environment.commit_email_format =="html": 795 self._contains_html_diff =True 796 797def_compute_values(self): 798"""Return a dictionary{keyword: expansion}for this Change. 799 800 Derived classes overload this method to add more entries to 801 the return value. This method is used internally by 802 get_values(). The return value should always be a new 803 dictionary.""" 804 805 values = self.environment.get_values() 806 fromaddr = self.environment.get_fromaddr(change=self) 807if fromaddr is not None: 808 values['fromaddr'] = fromaddr 809 values['multimail_version'] =get_version() 810return values 811 812# Aliases usable in template strings. Tuple of pairs (destination, 813# source). 814 VALUES_ALIAS = ( 815("id","newrev"), 816) 817 818defget_values(self, **extra_values): 819"""Return a dictionary{keyword: expansion}for this Change. 820 821 Return a dictionary mapping keywords to the values that they 822 should be expanded to for this Change (used when interpolating 823 template strings). If any keyword arguments are supplied, add 824 those to the return value as well. The return value is always 825 a new dictionary.""" 826 827if self._values is None: 828 self._values = self._compute_values() 829 830 values = self._values.copy() 831if extra_values: 832 values.update(extra_values) 833 834for alias, val in self.VALUES_ALIAS: 835 values[alias] = values[val] 836return values 837 838defexpand(self, template, **extra_values): 839"""Expand template. 840 841 Expand the template (which should be a string) using string 842 interpolation of the values for this Change. If any keyword 843 arguments are provided, also include those in the keywords 844 available for interpolation.""" 845 846return template % self.get_values(**extra_values) 847 848defexpand_lines(self, template, html_escape_val=False, **extra_values): 849"""Break template into lines and expand each line.""" 850 851 values = self.get_values(**extra_values) 852if html_escape_val: 853for k in values: 854ifis_string(values[k]): 855 values[k] = cgi.escape(values[k],True) 856for line in template.splitlines(True): 857yield line % values 858 859defexpand_header_lines(self, template, **extra_values): 860"""Break template into lines and expand each line as an RFC 2822 header. 861 862 Encode values and split up lines that are too long. Silently 863 skip lines that contain references to unknown variables.""" 864 865 values = self.get_values(**extra_values) 866if self._contains_html_diff: 867 self._content_type ='html' 868else: 869 self._content_type ='plain' 870 values['contenttype'] = self._content_type 871 872for line in template.splitlines(): 873(name, value) = line.split(': ',1) 874 875try: 876 value = value % values 877exceptKeyError: 878 t, e, traceback = sys.exc_info() 879if DEBUG: 880 self.environment.log_warning( 881'Warning: unknown variable%rin the following line; line skipped:\n' 882'%s\n' 883% (e.args[0], line,) 884) 885else: 886if name.lower()in ADDR_HEADERS: 887 value =addr_header_encode(value, name) 888else: 889 value =header_encode(value, name) 890for splitline in('%s:%s\n'% (name, value)).splitlines(True): 891yield splitline 892 893defgenerate_email_header(self): 894"""Generate the RFC 2822 email headers for this Change, a line at a time. 895 896 The output should not include the trailing blank line.""" 897 898raiseNotImplementedError() 899 900defgenerate_browse_link(self, base_url): 901"""Generate a link to an online repository browser.""" 902returniter(()) 903 904defgenerate_email_intro(self, html_escape_val=False): 905"""Generate the email intro for this Change, a line at a time. 906 907 The output will be used as the standard boilerplate at the top 908 of the email body.""" 909 910raiseNotImplementedError() 911 912defgenerate_email_body(self): 913"""Generate the main part of the email body, a line at a time. 914 915 The text in the body might be truncated after a specified 916 number of lines (see multimailhook.emailmaxlines).""" 917 918raiseNotImplementedError() 919 920defgenerate_email_footer(self, html_escape_val): 921"""Generate the footer of the email, a line at a time. 922 923 The footer is always included, irrespective of 924 multimailhook.emailmaxlines.""" 925 926raiseNotImplementedError() 927 928def_wrap_for_html(self, lines): 929"""Wrap the lines in HTML <pre> tag when using HTML format. 930 931 Escape special HTML characters and add <pre> and </pre> tags around 932 the given lines if we should be generating HTML as indicated by 933 self._contains_html_diff being set to true. 934 """ 935if self._contains_html_diff: 936yield"<pre style='margin:0'>\n" 937 938for line in lines: 939yield cgi.escape(line) 940 941yield'</pre>\n' 942else: 943for line in lines: 944yield line 945 946defgenerate_email(self, push, body_filter=None, extra_header_values={}): 947"""Generate an email describing this change. 948 949 Iterate over the lines (including the header lines) of an 950 email describing this change. If body_filter is not None, 951 then use it to filter the lines that are intended for the 952 email body. 953 954 The extra_header_values field is received as a dict and not as 955 **kwargs, to allow passing other keyword arguments in the 956 future (e.g. passing extra values to generate_email_intro()""" 957 958for line in self.generate_email_header(**extra_header_values): 959yield line 960yield'\n' 961 html_escape_val = (self.environment.html_in_intro and 962 self._contains_html_diff) 963 intro = self.generate_email_intro(html_escape_val) 964if not self.environment.html_in_intro: 965 intro = self._wrap_for_html(intro) 966for line in intro: 967yield line 968 969if self.environment.commitBrowseURL: 970for line in self.generate_browse_link(self.environment.commitBrowseURL): 971yield line 972 973 body = self.generate_email_body(push) 974if body_filter is not None: 975 body =body_filter(body) 976 977 diff_started =False 978if self._contains_html_diff: 979# "white-space: pre" is the default, but we need to 980# specify it again in case the message is viewed in a 981# webmail which wraps it in an element setting white-space 982# to something else (Zimbra does this and sets 983# white-space: pre-line). 984yield'<pre style="white-space: pre; background: #F8F8F8">' 985for line in body: 986if self._contains_html_diff: 987# This is very, very naive. It would be much better to really 988# parse the diff, i.e. look at how many lines do we have in 989# the hunk headers instead of blindly highlighting everything 990# that looks like it might be part of a diff. 991 bgcolor ='' 992 fgcolor ='' 993if line.startswith('--- a/'): 994 diff_started =True 995 bgcolor ='e0e0ff' 996elif line.startswith('diff ')or line.startswith('index '): 997 diff_started =True 998 fgcolor ='808080' 999elif diff_started:1000if line.startswith('+++ '):1001 bgcolor ='e0e0ff'1002elif line.startswith('@@'):1003 bgcolor ='e0e0e0'1004elif line.startswith('+'):1005 bgcolor ='e0ffe0'1006elif line.startswith('-'):1007 bgcolor ='ffe0e0'1008elif line.startswith('commit '):1009 fgcolor ='808000'1010elif line.startswith(' '):1011 fgcolor ='404040'10121013# Chop the trailing LF, we don't want it inside <pre>.1014 line = cgi.escape(line[:-1])10151016if bgcolor or fgcolor:1017 style ='display:block; white-space:pre;'1018if bgcolor:1019 style +='background:#'+ bgcolor +';'1020if fgcolor:1021 style +='color:#'+ fgcolor +';'1022# Use a <span style='display:block> to color the1023# whole line. The newline must be inside the span1024# to display properly both in Firefox and in1025# text-based browser.1026 line ="<span style='%s'>%s\n</span>"% (style, line)1027else:1028 line = line +'\n'10291030yield line1031if self._contains_html_diff:1032yield'</pre>'1033 html_escape_val = (self.environment.html_in_footer and1034 self._contains_html_diff)1035 footer = self.generate_email_footer(html_escape_val)1036if not self.environment.html_in_footer:1037 footer = self._wrap_for_html(footer)1038for line in footer:1039yield line10401041defget_specific_fromaddr(self):1042"""For kinds of Changes which specify it, return the kind-specific1043 From address to use."""1044return None104510461047classRevision(Change):1048"""A Change consisting of a single git commit."""10491050 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')10511052def__init__(self, reference_change, rev, num, tot):1053 Change.__init__(self, reference_change.environment)1054 self.reference_change = reference_change1055 self.rev = rev1056 self.change_type = self.reference_change.change_type1057 self.refname = self.reference_change.refname1058 self.num = num1059 self.tot = tot1060 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1])1061 self.recipients = self.environment.get_revision_recipients(self)10621063 self.cc_recipients =''1064if self.environment.get_scancommitforcc():1065 self.cc_recipients =', '.join(to.strip()for to in self._cc_recipients())1066if self.cc_recipients:1067 self.environment.log_msg(1068'Add%sto CC for%s'% (self.cc_recipients, self.rev.sha1))10691070def_cc_recipients(self):1071 cc_recipients = []1072 message =read_git_output(['log','--no-walk','--format=%b', self.rev.sha1])1073 lines = message.strip().split('\n')1074for line in lines:1075 m = re.match(self.CC_RE, line)1076if m:1077 cc_recipients.append(m.group('to'))10781079return cc_recipients10801081def_compute_values(self):1082 values = Change._compute_values(self)10831084 oneline =read_git_output(1085['log','--format=%s','--no-walk', self.rev.sha1]1086)10871088 max_subject_length = self.environment.get_max_subject_length()1089if max_subject_length >0andlen(oneline) > max_subject_length:1090 oneline = oneline[:max_subject_length -6] +' [...]'10911092 values['rev'] = self.rev.sha11093 values['rev_short'] = self.rev.short1094 values['change_type'] = self.change_type1095 values['refname'] = self.refname1096 values['newrev'] = self.rev.sha11097 values['short_refname'] = self.reference_change.short_refname1098 values['refname_type'] = self.reference_change.refname_type1099 values['reply_to_msgid'] = self.reference_change.msgid1100 values['num'] = self.num1101 values['tot'] = self.tot1102 values['recipients'] = self.recipients1103if self.cc_recipients:1104 values['cc_recipients'] = self.cc_recipients1105 values['oneline'] = oneline1106 values['author'] = self.author11071108 reply_to = self.environment.get_reply_to_commit(self)1109if reply_to:1110 values['reply_to'] = reply_to11111112return values11131114defgenerate_email_header(self, **extra_values):1115for line in self.expand_header_lines(1116 REVISION_HEADER_TEMPLATE, **extra_values1117):1118yield line11191120defgenerate_browse_link(self, base_url):1121if'%('not in base_url:1122 base_url +='%(id)s'1123 url ="".join(self.expand_lines(base_url))1124if self._content_type =='html':1125for line in self.expand_lines(LINK_HTML_TEMPLATE,1126 html_escape_val=True,1127 browse_url=url):1128yield line1129elif self._content_type =='plain':1130for line in self.expand_lines(LINK_TEXT_TEMPLATE,1131 html_escape_val=False,1132 browse_url=url):1133yield line1134else:1135raiseNotImplementedError("Content-type%sunsupported. Please report it as a bug.")11361137defgenerate_email_intro(self, html_escape_val=False):1138for line in self.expand_lines(REVISION_INTRO_TEMPLATE,1139 html_escape_val=html_escape_val):1140yield line11411142defgenerate_email_body(self, push):1143"""Show this revision."""11441145for line inread_git_lines(1146['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],1147 keepends=True,1148 errors='replace'):1149if line.startswith('Date: ')and self.environment.date_substitute:1150yield self.environment.date_substitute + line[len('Date: '):]1151else:1152yield line11531154defgenerate_email_footer(self, html_escape_val):1155return self.expand_lines(REVISION_FOOTER_TEMPLATE,1156 html_escape_val=html_escape_val)11571158defgenerate_email(self, push, body_filter=None, extra_header_values={}):1159 self._contains_diff()1160return Change.generate_email(self, push, body_filter, extra_header_values)11611162defget_specific_fromaddr(self):1163return self.environment.from_commit116411651166classReferenceChange(Change):1167"""A Change to a Git reference.11681169 An abstract class representing a create, update, or delete of a1170 Git reference. Derived classes handle specific types of reference1171 (e.g., tags vs. branches). These classes generate the main1172 reference change email summarizing the reference change and1173 whether it caused any any commits to be added or removed.11741175 ReferenceChange objects are usually created using the static1176 create() method, which has the logic to decide which derived class1177 to instantiate."""11781179 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')11801181@staticmethod1182defcreate(environment, oldrev, newrev, refname):1183"""Return a ReferenceChange object representing the change.11841185 Return an object that represents the type of change that is being1186 made. oldrev and newrev should be SHA1s or ZEROS."""11871188 old =GitObject(oldrev)1189 new =GitObject(newrev)1190 rev = new or old11911192# The revision type tells us what type the commit is, combined with1193# the location of the ref we can decide between1194# - working branch1195# - tracking branch1196# - unannotated tag1197# - annotated tag1198 m = ReferenceChange.REF_RE.match(refname)1199if m:1200 area = m.group('area')1201 short_refname = m.group('shortname')1202else:1203 area =''1204 short_refname = refname12051206if rev.type=='tag':1207# Annotated tag:1208 klass = AnnotatedTagChange1209elif rev.type=='commit':1210if area =='tags':1211# Non-annotated tag:1212 klass = NonAnnotatedTagChange1213elif area =='heads':1214# Branch:1215 klass = BranchChange1216elif area =='remotes':1217# Tracking branch:1218 environment.log_warning(1219'*** Push-update of tracking branch%r\n'1220'*** - incomplete email generated.'1221% (refname,)1222)1223 klass = OtherReferenceChange1224else:1225# Some other reference namespace:1226 environment.log_warning(1227'*** Push-update of strange reference%r\n'1228'*** - incomplete email generated.'1229% (refname,)1230)1231 klass = OtherReferenceChange1232else:1233# Anything else (is there anything else?)1234 environment.log_warning(1235'*** Unknown type of update to%r(%s)\n'1236'*** - incomplete email generated.'1237% (refname, rev.type,)1238)1239 klass = OtherReferenceChange12401241returnklass(1242 environment,1243 refname=refname, short_refname=short_refname,1244 old=old, new=new, rev=rev,1245)12461247def__init__(self, environment, refname, short_refname, old, new, rev):1248 Change.__init__(self, environment)1249 self.change_type = {1250(False,True):'create',1251(True,True):'update',1252(True,False):'delete',1253}[bool(old),bool(new)]1254 self.refname = refname1255 self.short_refname = short_refname1256 self.old = old1257 self.new = new1258 self.rev = rev1259 self.msgid =make_msgid()1260 self.diffopts = environment.diffopts1261 self.graphopts = environment.graphopts1262 self.logopts = environment.logopts1263 self.commitlogopts = environment.commitlogopts1264 self.showgraph = environment.refchange_showgraph1265 self.showlog = environment.refchange_showlog12661267 self.header_template = REFCHANGE_HEADER_TEMPLATE1268 self.intro_template = REFCHANGE_INTRO_TEMPLATE1269 self.footer_template = FOOTER_TEMPLATE12701271def_compute_values(self):1272 values = Change._compute_values(self)12731274 values['change_type'] = self.change_type1275 values['refname_type'] = self.refname_type1276 values['refname'] = self.refname1277 values['short_refname'] = self.short_refname1278 values['msgid'] = self.msgid1279 values['recipients'] = self.recipients1280 values['oldrev'] =str(self.old)1281 values['oldrev_short'] = self.old.short1282 values['newrev'] =str(self.new)1283 values['newrev_short'] = self.new.short12841285if self.old:1286 values['oldrev_type'] = self.old.type1287if self.new:1288 values['newrev_type'] = self.new.type12891290 reply_to = self.environment.get_reply_to_refchange(self)1291if reply_to:1292 values['reply_to'] = reply_to12931294return values12951296defsend_single_combined_email(self, known_added_sha1s):1297"""Determine if a combined refchange/revision email should be sent12981299 If there is only a single new (non-merge) commit added by a1300 change, it is useful to combine the ReferenceChange and1301 Revision emails into one. In such a case, return the single1302 revision; otherwise, return None.13031304 This method is overridden in BranchChange."""13051306return None13071308defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1309"""Generate an email describing this change AND specified revision.13101311 Iterate over the lines (including the header lines) of an1312 email describing this change. If body_filter is not None,1313 then use it to filter the lines that are intended for the1314 email body.13151316 The extra_header_values field is received as a dict and not as1317 **kwargs, to allow passing other keyword arguments in the1318 future (e.g. passing extra values to generate_email_intro()13191320 This method is overridden in BranchChange."""13211322raiseNotImplementedError13231324defget_subject(self):1325 template = {1326'create': REF_CREATED_SUBJECT_TEMPLATE,1327'update': REF_UPDATED_SUBJECT_TEMPLATE,1328'delete': REF_DELETED_SUBJECT_TEMPLATE,1329}[self.change_type]1330return self.expand(template)13311332defgenerate_email_header(self, **extra_values):1333if'subject'not in extra_values:1334 extra_values['subject'] = self.get_subject()13351336for line in self.expand_header_lines(1337 self.header_template, **extra_values1338):1339yield line13401341defgenerate_email_intro(self, html_escape_val=False):1342for line in self.expand_lines(self.intro_template,1343 html_escape_val=html_escape_val):1344yield line13451346defgenerate_email_body(self, push):1347"""Call the appropriate body-generation routine.13481349 Call one of generate_create_summary() /1350 generate_update_summary() / generate_delete_summary()."""13511352 change_summary = {1353'create': self.generate_create_summary,1354'delete': self.generate_delete_summary,1355'update': self.generate_update_summary,1356}[self.change_type](push)1357for line in change_summary:1358yield line13591360for line in self.generate_revision_change_summary(push):1361yield line13621363defgenerate_email_footer(self, html_escape_val):1364return self.expand_lines(self.footer_template,1365 html_escape_val=html_escape_val)13661367defgenerate_revision_change_graph(self, push):1368if self.showgraph:1369 args = ['--graph'] + self.graphopts1370for newold in('new','old'):1371 has_newold =False1372 spec = push.get_commits_spec(newold, self)1373for line ingit_log(spec, args=args, keepends=True):1374if not has_newold:1375 has_newold =True1376yield'\n'1377yield'Graph of%scommits:\n\n'% (1378 {'new': 'new', 'old': 'discarded'}[newold],)1379yield' '+ line1380if has_newold:1381yield'\n'13821383defgenerate_revision_change_log(self, new_commits_list):1384if self.showlog:1385yield'\n'1386yield'Detailed log of new commits:\n\n'1387for line inread_git_lines(1388['log','--no-walk'] +1389 self.logopts +1390 new_commits_list +1391['--'],1392 keepends=True,1393):1394yield line13951396defgenerate_new_revision_summary(self, tot, new_commits_list, push):1397for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):1398yield line1399for line in self.generate_revision_change_graph(push):1400yield line1401for line in self.generate_revision_change_log(new_commits_list):1402yield line14031404defgenerate_revision_change_summary(self, push):1405"""Generate a summary of the revisions added/removed by this change."""14061407if self.new.commit_sha1 and not self.old.commit_sha1:1408# A new reference was created. List the new revisions1409# brought by the new reference (i.e., those revisions that1410# were not in the repository before this reference1411# change).1412 sha1s =list(push.get_new_commits(self))1413 sha1s.reverse()1414 tot =len(sha1s)1415 new_revisions = [1416Revision(self,GitObject(sha1), num=i +1, tot=tot)1417for(i, sha1)inenumerate(sha1s)1418]14191420if new_revisions:1421yield self.expand('This%(refname_type)sincludes the following new commits:\n')1422yield'\n'1423for r in new_revisions:1424(sha1, subject) = r.rev.get_summary()1425yield r.expand(1426 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,1427)1428yield'\n'1429for line in self.generate_new_revision_summary(1430 tot, [r.rev.sha1 for r in new_revisions], push):1431yield line1432else:1433for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1434yield line14351436elif self.new.commit_sha1 and self.old.commit_sha1:1437# A reference was changed to point at a different commit.1438# List the revisions that were removed and/or added *from1439# that reference* by this reference change, along with a1440# diff between the trees for its old and new values.14411442# List of the revisions that were added to the branch by1443# this update. Note this list can include revisions that1444# have already had notification emails; we want such1445# revisions in the summary even though we will not send1446# new notification emails for them.1447 adds =list(generate_summaries(1448'--topo-order','--reverse','%s..%s'1449% (self.old.commit_sha1, self.new.commit_sha1,)1450))14511452# List of the revisions that were removed from the branch1453# by this update. This will be empty except for1454# non-fast-forward updates.1455 discards =list(generate_summaries(1456'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,)1457))14581459if adds:1460 new_commits_list = push.get_new_commits(self)1461else:1462 new_commits_list = []1463 new_commits =CommitSet(new_commits_list)14641465if discards:1466 discarded_commits =CommitSet(push.get_discarded_commits(self))1467else:1468 discarded_commits =CommitSet([])14691470if discards and adds:1471for(sha1, subject)in discards:1472if sha1 in discarded_commits:1473 action ='discard'1474else:1475 action ='omit'1476yield self.expand(1477 BRIEF_SUMMARY_TEMPLATE, action=action,1478 rev_short=sha1, text=subject,1479)1480for(sha1, subject)in adds:1481if sha1 in new_commits:1482 action ='new'1483else:1484 action ='add'1485yield self.expand(1486 BRIEF_SUMMARY_TEMPLATE, action=action,1487 rev_short=sha1, text=subject,1488)1489yield'\n'1490for line in self.expand_lines(NON_FF_TEMPLATE):1491yield line14921493elif discards:1494for(sha1, subject)in discards:1495if sha1 in discarded_commits:1496 action ='discard'1497else:1498 action ='omit'1499yield self.expand(1500 BRIEF_SUMMARY_TEMPLATE, action=action,1501 rev_short=sha1, text=subject,1502)1503yield'\n'1504for line in self.expand_lines(REWIND_ONLY_TEMPLATE):1505yield line15061507elif adds:1508(sha1, subject) = self.old.get_summary()1509yield self.expand(1510 BRIEF_SUMMARY_TEMPLATE, action='from',1511 rev_short=sha1, text=subject,1512)1513for(sha1, subject)in adds:1514if sha1 in new_commits:1515 action ='new'1516else:1517 action ='add'1518yield self.expand(1519 BRIEF_SUMMARY_TEMPLATE, action=action,1520 rev_short=sha1, text=subject,1521)15221523yield'\n'15241525if new_commits:1526for line in self.generate_new_revision_summary(1527len(new_commits), new_commits_list, push):1528yield line1529else:1530for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1531yield line1532for line in self.generate_revision_change_graph(push):1533yield line15341535# The diffstat is shown from the old revision to the new1536# revision. This is to show the truth of what happened in1537# this change. There's no point showing the stat from the1538# base to the new revision because the base is effectively a1539# random revision at this point - the user will be interested1540# in what this revision changed - including the undoing of1541# previous revisions in the case of non-fast-forward updates.1542yield'\n'1543yield'Summary of changes:\n'1544for line inread_git_lines(1545['diff-tree'] +1546 self.diffopts +1547['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1548 keepends=True,1549):1550yield line15511552elif self.old.commit_sha1 and not self.new.commit_sha1:1553# A reference was deleted. List the revisions that were1554# removed from the repository by this reference change.15551556 sha1s =list(push.get_discarded_commits(self))1557 tot =len(sha1s)1558 discarded_revisions = [1559Revision(self,GitObject(sha1), num=i +1, tot=tot)1560for(i, sha1)inenumerate(sha1s)1561]15621563if discarded_revisions:1564for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1565yield line1566yield'\n'1567for r in discarded_revisions:1568(sha1, subject) = r.rev.get_summary()1569yield r.expand(1570 BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,1571)1572for line in self.generate_revision_change_graph(push):1573yield line1574else:1575for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1576yield line15771578elif not self.old.commit_sha1 and not self.new.commit_sha1:1579for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1580yield line15811582defgenerate_create_summary(self, push):1583"""Called for the creation of a reference."""15841585# This is a new reference and so oldrev is not valid1586(sha1, subject) = self.new.get_summary()1587yield self.expand(1588 BRIEF_SUMMARY_TEMPLATE, action='at',1589 rev_short=sha1, text=subject,1590)1591yield'\n'15921593defgenerate_update_summary(self, push):1594"""Called for the change of a pre-existing branch."""15951596returniter([])15971598defgenerate_delete_summary(self, push):1599"""Called for the deletion of any type of reference."""16001601(sha1, subject) = self.old.get_summary()1602yield self.expand(1603 BRIEF_SUMMARY_TEMPLATE, action='was',1604 rev_short=sha1, text=subject,1605)1606yield'\n'16071608defget_specific_fromaddr(self):1609return self.environment.from_refchange161016111612classBranchChange(ReferenceChange):1613 refname_type ='branch'16141615def__init__(self, environment, refname, short_refname, old, new, rev):1616 ReferenceChange.__init__(1617 self, environment,1618 refname=refname, short_refname=short_refname,1619 old=old, new=new, rev=rev,1620)1621 self.recipients = environment.get_refchange_recipients(self)1622 self._single_revision =None16231624defsend_single_combined_email(self, known_added_sha1s):1625if not self.environment.combine_when_single_commit:1626return None16271628# In the sadly-all-too-frequent usecase of people pushing only1629# one of their commits at a time to a repository, users feel1630# the reference change summary emails are noise rather than1631# important signal. This is because, in this particular1632# usecase, there is a reference change summary email for each1633# new commit, and all these summaries do is point out that1634# there is one new commit (which can readily be inferred by1635# the existence of the individual revision email that is also1636# sent). In such cases, our users prefer there to be a combined1637# reference change summary/new revision email.1638#1639# So, if the change is an update and it doesn't discard any1640# commits, and it adds exactly one non-merge commit (gerrit1641# forces a workflow where every commit is individually merged1642# and the git-multimail hook fired off for just this one1643# change), then we send a combined refchange/revision email.1644try:1645# If this change is a reference update that doesn't discard1646# any commits...1647if self.change_type !='update':1648return None16491650ifread_git_lines(1651['merge-base', self.old.sha1, self.new.sha1]1652) != [self.old.sha1]:1653return None16541655# Check if this update introduced exactly one non-merge1656# commit:16571658defsplit_line(line):1659"""Split line into (sha1, [parent,...])."""16601661 words = line.split()1662return(words[0], words[1:])16631664# Get the new commits introduced by the push as a list of1665# (sha1, [parent,...])1666 new_commits = [1667split_line(line)1668for line inread_git_lines(1669[1670'log','-3','--format=%H %P',1671'%s..%s'% (self.old.sha1, self.new.sha1),1672]1673)1674]16751676if not new_commits:1677return None16781679# If the newest commit is a merge, save it for a later check1680# but otherwise ignore it1681 merge =None1682 tot =len(new_commits)1683iflen(new_commits[0][1]) >1:1684 merge = new_commits[0][0]1685del new_commits[0]16861687# Our primary check: we can't combine if more than one commit1688# is introduced. We also currently only combine if the new1689# commit is a non-merge commit, though it may make sense to1690# combine if it is a merge as well.1691if not(1692len(new_commits) ==1and1693len(new_commits[0][1]) ==1and1694 new_commits[0][0]in known_added_sha1s1695):1696return None16971698# We do not want to combine revision and refchange emails if1699# those go to separate locations.1700 rev =Revision(self,GitObject(new_commits[0][0]),1, tot)1701if rev.recipients != self.recipients:1702return None17031704# We ignored the newest commit if it was just a merge of the one1705# commit being introduced. But we don't want to ignore that1706# merge commit it it involved conflict resolutions. Check that.1707if merge and merge !=read_git_output(['diff-tree','--cc', merge]):1708return None17091710# We can combine the refchange and one new revision emails1711# into one. Return the Revision that a combined email should1712# be sent about.1713return rev1714except CommandError:1715# Cannot determine number of commits in old..new or new..old;1716# don't combine reference/revision emails:1717return None17181719defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1720 values = revision.get_values()1721if extra_header_values:1722 values.update(extra_header_values)1723if'subject'not in extra_header_values:1724 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)17251726 self._single_revision = revision1727 self._contains_diff()1728 self.header_template = COMBINED_HEADER_TEMPLATE1729 self.intro_template = COMBINED_INTRO_TEMPLATE1730 self.footer_template = COMBINED_FOOTER_TEMPLATE17311732defrevision_gen_link(base_url):1733# revision is used only to generate the body, and1734# _content_type is set while generating headers. Get it1735# from the BranchChange object.1736 revision._content_type = self._content_type1737return revision.generate_browse_link(base_url)1738 self.generate_browse_link = revision_gen_link1739for line in self.generate_email(push, body_filter, values):1740yield line17411742defgenerate_email_body(self, push):1743'''Call the appropriate body generation routine.17441745 If this is a combined refchange/revision email, the special logic1746 for handling this combined email comes from this function. For1747 other cases, we just use the normal handling.'''17481749# If self._single_revision isn't set; don't override1750if not self._single_revision:1751for line insuper(BranchChange, self).generate_email_body(push):1752yield line1753return17541755# This is a combined refchange/revision email; we first provide1756# some info from the refchange portion, and then call the revision1757# generate_email_body function to handle the revision portion.1758 adds =list(generate_summaries(1759'--topo-order','--reverse','%s..%s'1760% (self.old.commit_sha1, self.new.commit_sha1,)1761))17621763yield self.expand("The following commit(s) were added to%(refname)sby this push:\n")1764for(sha1, subject)in adds:1765yield self.expand(1766 BRIEF_SUMMARY_TEMPLATE, action='new',1767 rev_short=sha1, text=subject,1768)17691770yield self._single_revision.rev.short +" is described below\n"1771yield'\n'17721773for line in self._single_revision.generate_email_body(push):1774yield line177517761777classAnnotatedTagChange(ReferenceChange):1778 refname_type ='annotated tag'17791780def__init__(self, environment, refname, short_refname, old, new, rev):1781 ReferenceChange.__init__(1782 self, environment,1783 refname=refname, short_refname=short_refname,1784 old=old, new=new, rev=rev,1785)1786 self.recipients = environment.get_announce_recipients(self)1787 self.show_shortlog = environment.announce_show_shortlog17881789 ANNOTATED_TAG_FORMAT = (1790'%(*objectname)\n'1791'%(*objecttype)\n'1792'%(taggername)\n'1793'%(taggerdate)'1794)17951796defdescribe_tag(self, push):1797"""Describe the new value of an annotated tag."""17981799# Use git for-each-ref to pull out the individual fields from1800# the tag1801[tagobject, tagtype, tagger, tagged] =read_git_lines(1802['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1803)18041805yield self.expand(1806 BRIEF_SUMMARY_TEMPLATE, action='tagging',1807 rev_short=tagobject, text='(%s)'% (tagtype,),1808)1809if tagtype =='commit':1810# If the tagged object is a commit, then we assume this is a1811# release, and so we calculate which tag this tag is1812# replacing1813try:1814 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1815except CommandError:1816 prevtag =None1817if prevtag:1818yield' replaces%s\n'% (prevtag,)1819else:1820 prevtag =None1821yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)18221823yield' by%s\n'% (tagger,)1824yield' on%s\n'% (tagged,)1825yield'\n'18261827# Show the content of the tag message; this might contain a1828# change log or release notes so is worth displaying.1829yield LOGBEGIN1830 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1831 contents = contents[contents.index('\n') +1:]1832if contents and contents[-1][-1:] !='\n':1833 contents.append('\n')1834for line in contents:1835yield line18361837if self.show_shortlog and tagtype =='commit':1838# Only commit tags make sense to have rev-list operations1839# performed on them1840yield'\n'1841if prevtag:1842# Show changes since the previous release1843 revlist =read_git_output(1844['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1845 keepends=True,1846)1847else:1848# No previous tag, show all the changes since time1849# began1850 revlist =read_git_output(1851['rev-list','--pretty=short','%s'% (self.new,)],1852 keepends=True,1853)1854for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1855yield line18561857yield LOGEND1858yield'\n'18591860defgenerate_create_summary(self, push):1861"""Called for the creation of an annotated tag."""18621863for line in self.expand_lines(TAG_CREATED_TEMPLATE):1864yield line18651866for line in self.describe_tag(push):1867yield line18681869defgenerate_update_summary(self, push):1870"""Called for the update of an annotated tag.18711872 This is probably a rare event and may not even be allowed."""18731874for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1875yield line18761877for line in self.describe_tag(push):1878yield line18791880defgenerate_delete_summary(self, push):1881"""Called when a non-annotated reference is updated."""18821883for line in self.expand_lines(TAG_DELETED_TEMPLATE):1884yield line18851886yield self.expand(' tag was%(oldrev_short)s\n')1887yield'\n'188818891890classNonAnnotatedTagChange(ReferenceChange):1891 refname_type ='tag'18921893def__init__(self, environment, refname, short_refname, old, new, rev):1894 ReferenceChange.__init__(1895 self, environment,1896 refname=refname, short_refname=short_refname,1897 old=old, new=new, rev=rev,1898)1899 self.recipients = environment.get_refchange_recipients(self)19001901defgenerate_create_summary(self, push):1902"""Called for the creation of an annotated tag."""19031904for line in self.expand_lines(TAG_CREATED_TEMPLATE):1905yield line19061907defgenerate_update_summary(self, push):1908"""Called when a non-annotated reference is updated."""19091910for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1911yield line19121913defgenerate_delete_summary(self, push):1914"""Called when a non-annotated reference is updated."""19151916for line in self.expand_lines(TAG_DELETED_TEMPLATE):1917yield line19181919for line in ReferenceChange.generate_delete_summary(self, push):1920yield line192119221923classOtherReferenceChange(ReferenceChange):1924 refname_type ='reference'19251926def__init__(self, environment, refname, short_refname, old, new, rev):1927# We use the full refname as short_refname, because otherwise1928# the full name of the reference would not be obvious from the1929# text of the email.1930 ReferenceChange.__init__(1931 self, environment,1932 refname=refname, short_refname=refname,1933 old=old, new=new, rev=rev,1934)1935 self.recipients = environment.get_refchange_recipients(self)193619371938classMailer(object):1939"""An object that can send emails."""19401941def__init__(self, environment):1942 self.environment = environment19431944defsend(self, lines, to_addrs):1945"""Send an email consisting of lines.19461947 lines must be an iterable over the lines constituting the1948 header and body of the email. to_addrs is a list of recipient1949 addresses (can be needed even if lines already contains a1950 "To:" field). It can be either a string (comma-separated list1951 of email addresses) or a Python list of individual email1952 addresses.19531954 """19551956raiseNotImplementedError()195719581959classSendMailer(Mailer):1960"""Send emails using 'sendmail -oi -t'."""19611962 SENDMAIL_CANDIDATES = [1963'/usr/sbin/sendmail',1964'/usr/lib/sendmail',1965]19661967@staticmethod1968deffind_sendmail():1969for path in SendMailer.SENDMAIL_CANDIDATES:1970if os.access(path, os.X_OK):1971return path1972else:1973raiseConfigurationException(1974'No sendmail executable found. '1975'Try setting multimailhook.sendmailCommand.'1976)19771978def__init__(self, environment, command=None, envelopesender=None):1979"""Construct a SendMailer instance.19801981 command should be the command and arguments used to invoke1982 sendmail, as a list of strings. If an envelopesender is1983 provided, it will also be passed to the command, via '-f1984 envelopesender'."""1985super(SendMailer, self).__init__(environment)1986if command:1987 self.command = command[:]1988else:1989 self.command = [self.find_sendmail(),'-oi','-t']19901991if envelopesender:1992 self.command.extend(['-f', envelopesender])19931994defsend(self, lines, to_addrs):1995try:1996 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)1997exceptOSError:1998 self.environment.get_logger().error(1999'*** Cannot execute command:%s\n'%' '.join(self.command) +2000'***%s\n'% sys.exc_info()[1] +2001'*** Try setting multimailhook.mailer to "smtp"\n'+2002'*** to send emails without using the sendmail command.\n'2003)2004 sys.exit(1)2005try:2006 lines = (str_to_bytes(line)for line in lines)2007 p.stdin.writelines(lines)2008exceptException:2009 self.environment.get_logger().error(2010'*** Error while generating commit email\n'2011'*** - mail sending aborted.\n'2012)2013ifhasattr(p,'terminate'):2014# subprocess.terminate() is not available in Python 2.42015 p.terminate()2016else:2017import signal2018 os.kill(p.pid, signal.SIGTERM)2019raise2020else:2021 p.stdin.close()2022 retcode = p.wait()2023if retcode:2024raiseCommandError(self.command, retcode)202520262027classSMTPMailer(Mailer):2028"""Send emails using Python's smtplib."""20292030def__init__(self, environment,2031 envelopesender, smtpserver,2032 smtpservertimeout=10.0, smtpserverdebuglevel=0,2033 smtpencryption='none',2034 smtpuser='', smtppass='',2035 smtpcacerts=''2036):2037super(SMTPMailer, self).__init__(environment)2038if not envelopesender:2039 self.environment.get_logger().error(2040'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'2041'please set either multimailhook.envelopeSender or user.email\n'2042)2043 sys.exit(1)2044if smtpencryption =='ssl'and not(smtpuser and smtppass):2045raiseConfigurationException(2046'Cannot use SMTPMailer with security option ssl '2047'without options username and password.'2048)2049 self.envelopesender = envelopesender2050 self.smtpserver = smtpserver2051 self.smtpservertimeout = smtpservertimeout2052 self.smtpserverdebuglevel = smtpserverdebuglevel2053 self.security = smtpencryption2054 self.username = smtpuser2055 self.password = smtppass2056 self.smtpcacerts = smtpcacerts2057try:2058defcall(klass, server, timeout):2059try:2060returnklass(server, timeout=timeout)2061exceptTypeError:2062# Old Python versions do not have timeout= argument.2063returnklass(server)2064if self.security =='none':2065 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2066elif self.security =='ssl':2067if self.smtpcacerts:2068raise smtplib.SMTPException(2069"Checking certificate is not supported for ssl, prefer starttls"2070)2071 self.smtp =call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)2072elif self.security =='tls':2073if'ssl'not in sys.modules:2074 self.environment.get_logger().error(2075'*** Your Python version does not have the ssl library installed\n'2076'*** smtpEncryption=tls is not available.\n'2077'*** Either upgrade Python to 2.6 or later\n'2078' or use git_multimail.py version 1.2.\n')2079if':'not in self.smtpserver:2080 self.smtpserver +=':587'# default port for TLS2081 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2082# start: ehlo + starttls2083# equivalent to2084# self.smtp.ehlo()2085# self.smtp.starttls()2086# with acces to the ssl layer2087 self.smtp.ehlo()2088if not self.smtp.has_extn("starttls"):2089raise smtplib.SMTPException("STARTTLS extension not supported by server")2090 resp, reply = self.smtp.docmd("STARTTLS")2091if resp !=220:2092raise smtplib.SMTPException("Wrong answer to the STARTTLS command")2093if self.smtpcacerts:2094 self.smtp.sock = ssl.wrap_socket(2095 self.smtp.sock,2096 ca_certs=self.smtpcacerts,2097 cert_reqs=ssl.CERT_REQUIRED2098)2099else:2100 self.smtp.sock = ssl.wrap_socket(2101 self.smtp.sock,2102 cert_reqs=ssl.CERT_NONE2103)2104 self.environment.get_logger().error(2105'*** Warning, the server certificat is not verified (smtp) ***\n'2106'*** set the option smtpCACerts ***\n'2107)2108if nothasattr(self.smtp.sock,"read"):2109# using httplib.FakeSocket with Python 2.5.x or earlier2110 self.smtp.sock.read = self.smtp.sock.recv2111 self.smtp.file= smtplib.SSLFakeFile(self.smtp.sock)2112 self.smtp.helo_resp =None2113 self.smtp.ehlo_resp =None2114 self.smtp.esmtp_features = {}2115 self.smtp.does_esmtp =02116# end: ehlo + starttls2117 self.smtp.ehlo()2118else:2119 sys.stdout.write('*** Error: Control reached an invalid option. ***')2120 sys.exit(1)2121if self.smtpserverdebuglevel >0:2122 sys.stdout.write(2123"*** Setting debug on for SMTP server connection (%s) ***\n"2124% self.smtpserverdebuglevel)2125 self.smtp.set_debuglevel(self.smtpserverdebuglevel)2126exceptException:2127 self.environment.get_logger().error(2128'*** Error establishing SMTP connection to%s***\n'2129'***%s\n'2130% (self.smtpserver, sys.exc_info()[1]))2131 sys.exit(1)21322133def__del__(self):2134ifhasattr(self,'smtp'):2135 self.smtp.quit()2136del self.smtp21372138defsend(self, lines, to_addrs):2139try:2140if self.username or self.password:2141 self.smtp.login(self.username, self.password)2142 msg =''.join(lines)2143# turn comma-separated list into Python list if needed.2144ifis_string(to_addrs):2145 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]2146 self.smtp.sendmail(self.envelopesender, to_addrs, msg)2147except smtplib.SMTPResponseException:2148 err = sys.exc_info()[1]2149 self.environment.get_logger().error(2150'*** Error sending email ***\n'2151'*** Error%d:%s\n'2152% (err.smtp_code,bytes_to_str(err.smtp_error)))2153try:2154 smtp = self.smtp2155# delete the field before quit() so that in case of2156# error, self.smtp is deleted anyway.2157del self.smtp2158 smtp.quit()2159except:2160 self.environment.get_logger().error(2161'*** Error closing the SMTP connection ***\n'2162'*** Exiting anyway ... ***\n'2163'***%s\n'% sys.exc_info()[1])2164 sys.exit(1)216521662167classOutputMailer(Mailer):2168"""Write emails to an output stream, bracketed by lines of '=' characters.21692170 This is intended for debugging purposes."""21712172 SEPARATOR ='='*75+'\n'21732174def__init__(self, f):2175 self.f = f21762177defsend(self, lines, to_addrs):2178write_str(self.f, self.SEPARATOR)2179for line in lines:2180write_str(self.f, line)2181write_str(self.f, self.SEPARATOR)218221832184defget_git_dir():2185"""Determine GIT_DIR.21862187 Determine GIT_DIR either from the GIT_DIR environment variable or2188 from the working directory, using Git's usual rules."""21892190try:2191returnread_git_output(['rev-parse','--git-dir'])2192except CommandError:2193 sys.stderr.write('fatal: git_multimail: not in a git directory\n')2194 sys.exit(1)219521962197classEnvironment(object):2198"""Describes the environment in which the push is occurring.21992200 An Environment object encapsulates information about the local2201 environment. For example, it knows how to determine:22022203 * the name of the repository to which the push occurred22042205 * what user did the push22062207 * what users want to be informed about various types of changes.22082209 An Environment object is expected to have the following methods:22102211 get_repo_shortname()22122213 Return a short name for the repository, for display2214 purposes.22152216 get_repo_path()22172218 Return the absolute path to the Git repository.22192220 get_emailprefix()22212222 Return a string that will be prefixed to every email's2223 subject.22242225 get_pusher()22262227 Return the username of the person who pushed the changes.2228 This value is used in the email body to indicate who2229 pushed the change.22302231 get_pusher_email() (may return None)22322233 Return the email address of the person who pushed the2234 changes. The value should be a single RFC 2822 email2235 address as a string; e.g., "Joe User <user@example.com>"2236 if available, otherwise "user@example.com". If set, the2237 value is used as the Reply-To address for refchange2238 emails. If it is impossible to determine the pusher's2239 email, this attribute should be set to None (in which case2240 no Reply-To header will be output).22412242 get_sender()22432244 Return the address to be used as the 'From' email address2245 in the email envelope.22462247 get_fromaddr(change=None)22482249 Return the 'From' email address used in the email 'From:'2250 headers. If the change is known when this function is2251 called, it is passed in as the 'change' parameter. (May2252 be a full RFC 2822 email address like 'Joe User2253 <user@example.com>'.)22542255 get_administrator()22562257 Return the name and/or email of the repository2258 administrator. This value is used in the footer as the2259 person to whom requests to be removed from the2260 notification list should be sent. Ideally, it should2261 include a valid email address.22622263 get_reply_to_refchange()2264 get_reply_to_commit()22652266 Return the address to use in the email "Reply-To" header,2267 as a string. These can be an RFC 2822 email address, or2268 None to omit the "Reply-To" header.2269 get_reply_to_refchange() is used for refchange emails;2270 get_reply_to_commit() is used for individual commit2271 emails.22722273 get_ref_filter_regex()22742275 Return a tuple -- a compiled regex, and a boolean indicating2276 whether the regex picks refs to include (if False, the regex2277 matches on refs to exclude).22782279 get_default_ref_ignore_regex()22802281 Return a regex that should be ignored for both what emails2282 to send and when computing what commits are considered new2283 to the repository. Default is "^refs/notes/".22842285 get_max_subject_length()22862287 Return an int giving the maximal length for the subject2288 (git log --oneline).22892290 They should also define the following attributes:22912292 announce_show_shortlog (bool)22932294 True iff announce emails should include a shortlog.22952296 commit_email_format (string)22972298 If "html", generate commit emails in HTML instead of plain text2299 used by default.23002301 html_in_intro (bool)2302 html_in_footer (bool)23032304 When generating HTML emails, the introduction (respectively,2305 the footer) will be HTML-escaped iff html_in_intro (respectively,2306 the footer) is true. When false, only the values used to expand2307 the template are escaped.23082309 refchange_showgraph (bool)23102311 True iff refchanges emails should include a detailed graph.23122313 refchange_showlog (bool)23142315 True iff refchanges emails should include a detailed log.23162317 diffopts (list of strings)23182319 The options that should be passed to 'git diff' for the2320 summary email. The value should be a list of strings2321 representing words to be passed to the command.23222323 graphopts (list of strings)23242325 Analogous to diffopts, but contains options passed to2326 'git log --graph' when generating the detailed graph for2327 a set of commits (see refchange_showgraph)23282329 logopts (list of strings)23302331 Analogous to diffopts, but contains options passed to2332 'git log' when generating the detailed log for a set of2333 commits (see refchange_showlog)23342335 commitlogopts (list of strings)23362337 The options that should be passed to 'git log' for each2338 commit mail. The value should be a list of strings2339 representing words to be passed to the command.23402341 date_substitute (string)23422343 String to be used in substitution for 'Date:' at start of2344 line in the output of 'git log'.23452346 quiet (bool)2347 On success do not write to stderr23482349 stdout (bool)2350 Write email to stdout rather than emailing. Useful for debugging23512352 combine_when_single_commit (bool)23532354 True if a combined email should be produced when a single2355 new commit is pushed to a branch, False otherwise.23562357 from_refchange, from_commit (strings)23582359 Addresses to use for the From: field for refchange emails2360 and commit emails respectively. Set from2361 multimailhook.fromRefchange and multimailhook.fromCommit2362 by ConfigEnvironmentMixin.23632364 log_file, error_log_file, debug_log_file (string)23652366 Name of a file to which logs should be sent.23672368 verbose (int)23692370 How verbose the system should be.2371 - 0 (default): show info, errors, ...2372 - 1 : show basic debug info2373 """23742375 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')23762377def__init__(self, osenv=None):2378 self.osenv = osenv or os.environ2379 self.announce_show_shortlog =False2380 self.commit_email_format ="text"2381 self.html_in_intro =False2382 self.html_in_footer =False2383 self.commitBrowseURL =None2384 self.maxcommitemails =5002385 self.diffopts = ['--stat','--summary','--find-copies-harder']2386 self.graphopts = ['--oneline','--decorate']2387 self.logopts = []2388 self.refchange_showgraph =False2389 self.refchange_showlog =False2390 self.commitlogopts = ['-C','--stat','-p','--cc']2391 self.date_substitute ='AuthorDate: '2392 self.quiet =False2393 self.stdout =False2394 self.combine_when_single_commit =True2395 self.logger =None23962397 self.COMPUTED_KEYS = [2398'administrator',2399'charset',2400'emailprefix',2401'pusher',2402'pusher_email',2403'repo_path',2404'repo_shortname',2405'sender',2406]24072408 self._values =None24092410defget_logger(self):2411"""Get (possibly creates) the logger associated to this environment."""2412if self.logger is None:2413 self.logger =Logger(self)2414return self.logger24152416defget_repo_shortname(self):2417"""Use the last part of the repo path, with ".git" stripped off if present."""24182419 basename = os.path.basename(os.path.abspath(self.get_repo_path()))2420 m = self.REPO_NAME_RE.match(basename)2421if m:2422return m.group('name')2423else:2424return basename24252426defget_pusher(self):2427raiseNotImplementedError()24282429defget_pusher_email(self):2430return None24312432defget_fromaddr(self, change=None):2433 config =Config('user')2434 fromname = config.get('name', default='')2435 fromemail = config.get('email', default='')2436if fromemail:2437returnformataddr([fromname, fromemail])2438return self.get_sender()24392440defget_administrator(self):2441return'the administrator of this repository'24422443defget_emailprefix(self):2444return''24452446defget_repo_path(self):2447ifread_git_output(['rev-parse','--is-bare-repository']) =='true':2448 path =get_git_dir()2449else:2450 path =read_git_output(['rev-parse','--show-toplevel'])2451return os.path.abspath(path)24522453defget_charset(self):2454return CHARSET24552456defget_values(self):2457"""Return a dictionary{keyword: expansion}for this Environment.24582459 This method is called by Change._compute_values(). The keys2460 in the returned dictionary are available to be used in any of2461 the templates. The dictionary is created by calling2462 self.get_NAME() for each of the attributes named in2463 COMPUTED_KEYS and recording those that do not return None.2464 The return value is always a new dictionary."""24652466if self._values is None:2467 values = {'': ''} # %()s expands to the empty string.24682469for key in self.COMPUTED_KEYS:2470 value =getattr(self,'get_%s'% (key,))()2471if value is not None:2472 values[key] = value24732474 self._values = values24752476return self._values.copy()24772478defget_refchange_recipients(self, refchange):2479"""Return the recipients for notifications about refchange.24802481 Return the list of email addresses to which notifications2482 about the specified ReferenceChange should be sent."""24832484raiseNotImplementedError()24852486defget_announce_recipients(self, annotated_tag_change):2487"""Return the recipients for notifications about annotated_tag_change.24882489 Return the list of email addresses to which notifications2490 about the specified AnnotatedTagChange should be sent."""24912492raiseNotImplementedError()24932494defget_reply_to_refchange(self, refchange):2495return self.get_pusher_email()24962497defget_revision_recipients(self, revision):2498"""Return the recipients for messages about revision.24992500 Return the list of email addresses to which notifications2501 about the specified Revision should be sent. This method2502 could be overridden, for example, to take into account the2503 contents of the revision when deciding whom to notify about2504 it. For example, there could be a scheme for users to express2505 interest in particular files or subdirectories, and only2506 receive notification emails for revisions that affecting those2507 files."""25082509raiseNotImplementedError()25102511defget_reply_to_commit(self, revision):2512return revision.author25132514defget_default_ref_ignore_regex(self):2515# The commit messages of git notes are essentially meaningless2516# and "filenames" in git notes commits are an implementational2517# detail that might surprise users at first. As such, we2518# would need a completely different method for handling emails2519# of git notes in order for them to be of benefit for users,2520# which we simply do not have right now.2521return"^refs/notes/"25222523defget_max_subject_length(self):2524"""Return the maximal subject line (git log --oneline) length.2525 Longer subject lines will be truncated."""2526raiseNotImplementedError()25272528deffilter_body(self, lines):2529"""Filter the lines intended for an email body.25302531 lines is an iterable over the lines that would go into the2532 email body. Filter it (e.g., limit the number of lines, the2533 line length, character set, etc.), returning another iterable.2534 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin2535 for classes implementing this functionality."""25362537return lines25382539deflog_msg(self, msg):2540"""Write the string msg on a log file or on stderr.25412542 Sends the text to stderr by default, override to change the behavior."""2543 self.get_logger().info(msg)25442545deflog_warning(self, msg):2546"""Write the string msg on a log file or on stderr.25472548 Sends the text to stderr by default, override to change the behavior."""2549 self.get_logger().warning(msg)25502551deflog_error(self, msg):2552"""Write the string msg on a log file or on stderr.25532554 Sends the text to stderr by default, override to change the behavior."""2555 self.get_logger().error(msg)25562557defcheck(self):2558pass255925602561classConfigEnvironmentMixin(Environment):2562"""A mixin that sets self.config to its constructor's config argument.25632564 This class's constructor consumes the "config" argument.25652566 Mixins that need to inspect the config should inherit from this2567 class (1) to make sure that "config" is still in the constructor2568 arguments with its own constructor runs and/or (2) to be sure that2569 self.config is set after construction."""25702571def__init__(self, config, **kw):2572super(ConfigEnvironmentMixin, self).__init__(**kw)2573 self.config = config257425752576classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):2577"""An Environment that reads most of its information from "git config"."""25782579@staticmethod2580defforbid_field_values(name, value, forbidden):2581for forbidden_val in forbidden:2582if value is not None and value.lower() == forbidden:2583raiseConfigurationException(2584'"%s" is not an allowed setting for%s'% (value, name)2585)25862587def__init__(self, config, **kw):2588super(ConfigOptionsEnvironmentMixin, self).__init__(2589 config=config, **kw2590)25912592for var, cfg in(2593('announce_show_shortlog','announceshortlog'),2594('refchange_showgraph','refchangeShowGraph'),2595('refchange_showlog','refchangeshowlog'),2596('quiet','quiet'),2597('stdout','stdout'),2598):2599 val = config.get_bool(cfg)2600if val is not None:2601setattr(self, var, val)26022603 commit_email_format = config.get('commitEmailFormat')2604if commit_email_format is not None:2605if commit_email_format !="html"and commit_email_format !="text":2606 self.log_warning(2607'*** Unknown value for multimailhook.commitEmailFormat:%s\n'%2608 commit_email_format +2609'*** Expected either "text" or "html". Ignoring.\n'2610)2611else:2612 self.commit_email_format = commit_email_format26132614 html_in_intro = config.get_bool('htmlInIntro')2615if html_in_intro is not None:2616 self.html_in_intro = html_in_intro26172618 html_in_footer = config.get_bool('htmlInFooter')2619if html_in_footer is not None:2620 self.html_in_footer = html_in_footer26212622 self.commitBrowseURL = config.get('commitBrowseURL')26232624 maxcommitemails = config.get('maxcommitemails')2625if maxcommitemails is not None:2626try:2627 self.maxcommitemails =int(maxcommitemails)2628exceptValueError:2629 self.log_warning(2630'*** Malformed value for multimailhook.maxCommitEmails:%s\n'2631% maxcommitemails +2632'*** Expected a number. Ignoring.\n'2633)26342635 diffopts = config.get('diffopts')2636if diffopts is not None:2637 self.diffopts = shlex.split(diffopts)26382639 graphopts = config.get('graphOpts')2640if graphopts is not None:2641 self.graphopts = shlex.split(graphopts)26422643 logopts = config.get('logopts')2644if logopts is not None:2645 self.logopts = shlex.split(logopts)26462647 commitlogopts = config.get('commitlogopts')2648if commitlogopts is not None:2649 self.commitlogopts = shlex.split(commitlogopts)26502651 date_substitute = config.get('dateSubstitute')2652if date_substitute =='none':2653 self.date_substitute =None2654elif date_substitute is not None:2655 self.date_substitute = date_substitute26562657 reply_to = config.get('replyTo')2658 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)2659 self.forbid_field_values('replyToRefchange',2660 self.__reply_to_refchange,2661['author'])2662 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)26632664 self.from_refchange = config.get('fromRefchange')2665 self.forbid_field_values('fromRefchange',2666 self.from_refchange,2667['author','none'])2668 self.from_commit = config.get('fromCommit')2669 self.forbid_field_values('fromCommit',2670 self.from_commit,2671['none'])26722673 combine = config.get_bool('combineWhenSingleCommit')2674if combine is not None:2675 self.combine_when_single_commit = combine26762677 self.log_file = config.get('logFile', default=None)2678 self.error_log_file = config.get('errorLogFile', default=None)2679 self.debug_log_file = config.get('debugLogFile', default=None)2680if config.get_bool('Verbose', default=False):2681 self.verbose =12682else:2683 self.verbose =026842685defget_administrator(self):2686return(2687 self.config.get('administrator')or2688 self.get_sender()or2689super(ConfigOptionsEnvironmentMixin, self).get_administrator()2690)26912692defget_repo_shortname(self):2693return(2694 self.config.get('reponame')or2695super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()2696)26972698defget_emailprefix(self):2699 emailprefix = self.config.get('emailprefix')2700if emailprefix is not None:2701 emailprefix = emailprefix.strip()2702if emailprefix:2703 emailprefix +=' '2704else:2705 emailprefix ='[%(repo_shortname)s] '2706 short_name = self.get_repo_shortname()2707try:2708return emailprefix % {'repo_shortname': short_name}2709except:2710 self.get_logger().error(2711'*** Invalid multimailhook.emailPrefix:%s\n'% emailprefix +2712'***%s\n'% sys.exc_info()[1] +2713"*** Only the '%(repo_shortname)s' placeholder is allowed\n"2714)2715raiseConfigurationException(2716'"%s" is not an allowed setting for emailPrefix'% emailprefix2717)27182719defget_sender(self):2720return self.config.get('envelopesender')27212722defprocess_addr(self, addr, change):2723if addr.lower() =='author':2724ifhasattr(change,'author'):2725return change.author2726else:2727return None2728elif addr.lower() =='pusher':2729return self.get_pusher_email()2730elif addr.lower() =='none':2731return None2732else:2733return addr27342735defget_fromaddr(self, change=None):2736 fromaddr = self.config.get('from')2737if change:2738 specific_fromaddr = change.get_specific_fromaddr()2739if specific_fromaddr:2740 fromaddr = specific_fromaddr2741if fromaddr:2742 fromaddr = self.process_addr(fromaddr, change)2743if fromaddr:2744return fromaddr2745returnsuper(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)27462747defget_reply_to_refchange(self, refchange):2748if self.__reply_to_refchange is None:2749returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)2750else:2751return self.process_addr(self.__reply_to_refchange, refchange)27522753defget_reply_to_commit(self, revision):2754if self.__reply_to_commit is None:2755returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)2756else:2757return self.process_addr(self.__reply_to_commit, revision)27582759defget_scancommitforcc(self):2760return self.config.get('scancommitforcc')276127622763classFilterLinesEnvironmentMixin(Environment):2764"""Handle encoding and maximum line length of body lines.27652766 email_max_line_length (int or None)27672768 The maximum length of any single line in the email body.2769 Longer lines are truncated at that length with ' [...]'2770 appended.27712772 strict_utf8 (bool)27732774 If this field is set to True, then the email body text is2775 expected to be UTF-8. Any invalid characters are2776 converted to U+FFFD, the Unicode replacement character2777 (encoded as UTF-8, of course).27782779 """27802781def__init__(self, strict_utf8=True,2782 email_max_line_length=500, max_subject_length=500,2783**kw):2784super(FilterLinesEnvironmentMixin, self).__init__(**kw)2785 self.__strict_utf8= strict_utf82786 self.__email_max_line_length = email_max_line_length2787 self.__max_subject_length = max_subject_length27882789deffilter_body(self, lines):2790 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)2791if self.__strict_utf8:2792if not PYTHON3:2793 lines = (line.decode(ENCODING,'replace')for line in lines)2794# Limit the line length in Unicode-space to avoid2795# splitting characters:2796if self.__email_max_line_length >0:2797 lines =limit_linelength(lines, self.__email_max_line_length)2798if not PYTHON3:2799 lines = (line.encode(ENCODING,'replace')for line in lines)2800elif self.__email_max_line_length:2801 lines =limit_linelength(lines, self.__email_max_line_length)28022803return lines28042805defget_max_subject_length(self):2806return self.__max_subject_length280728082809classConfigFilterLinesEnvironmentMixin(2810 ConfigEnvironmentMixin,2811 FilterLinesEnvironmentMixin,2812):2813"""Handle encoding and maximum line length based on config."""28142815def__init__(self, config, **kw):2816 strict_utf8 = config.get_bool('emailstrictutf8', default=None)2817if strict_utf8 is not None:2818 kw['strict_utf8'] = strict_utf828192820 email_max_line_length = config.get('emailmaxlinelength')2821if email_max_line_length is not None:2822 kw['email_max_line_length'] =int(email_max_line_length)28232824 max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)2825if max_subject_length is not None:2826 kw['max_subject_length'] =int(max_subject_length)28272828super(ConfigFilterLinesEnvironmentMixin, self).__init__(2829 config=config, **kw2830)283128322833classMaxlinesEnvironmentMixin(Environment):2834"""Limit the email body to a specified number of lines."""28352836def__init__(self, emailmaxlines, **kw):2837super(MaxlinesEnvironmentMixin, self).__init__(**kw)2838 self.__emailmaxlines = emailmaxlines28392840deffilter_body(self, lines):2841 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)2842if self.__emailmaxlines >0:2843 lines =limit_lines(lines, self.__emailmaxlines)2844return lines284528462847classConfigMaxlinesEnvironmentMixin(2848 ConfigEnvironmentMixin,2849 MaxlinesEnvironmentMixin,2850):2851"""Limit the email body to the number of lines specified in config."""28522853def__init__(self, config, **kw):2854 emailmaxlines =int(config.get('emailmaxlines', default='0'))2855super(ConfigMaxlinesEnvironmentMixin, self).__init__(2856 config=config,2857 emailmaxlines=emailmaxlines,2858**kw2859)286028612862classFQDNEnvironmentMixin(Environment):2863"""A mixin that sets the host's FQDN to its constructor argument."""28642865def__init__(self, fqdn, **kw):2866super(FQDNEnvironmentMixin, self).__init__(**kw)2867 self.COMPUTED_KEYS += ['fqdn']2868 self.__fqdn = fqdn28692870defget_fqdn(self):2871"""Return the fully-qualified domain name for this host.28722873 Return None if it is unavailable or unwanted."""28742875return self.__fqdn287628772878classConfigFQDNEnvironmentMixin(2879 ConfigEnvironmentMixin,2880 FQDNEnvironmentMixin,2881):2882"""Read the FQDN from the config."""28832884def__init__(self, config, **kw):2885 fqdn = config.get('fqdn')2886super(ConfigFQDNEnvironmentMixin, self).__init__(2887 config=config,2888 fqdn=fqdn,2889**kw2890)289128922893classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):2894"""Get the FQDN by calling socket.getfqdn()."""28952896def__init__(self, **kw):2897super(ComputeFQDNEnvironmentMixin, self).__init__(2898 fqdn=socket.getfqdn(),2899**kw2900)290129022903classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):2904"""Deduce pusher_email from pusher by appending an emaildomain."""29052906def__init__(self, **kw):2907super(PusherDomainEnvironmentMixin, self).__init__(**kw)2908 self.__emaildomain = self.config.get('emaildomain')29092910defget_pusher_email(self):2911if self.__emaildomain:2912# Derive the pusher's full email address in the default way:2913return'%s@%s'% (self.get_pusher(), self.__emaildomain)2914else:2915returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()291629172918classStaticRecipientsEnvironmentMixin(Environment):2919"""Set recipients statically based on constructor parameters."""29202921def__init__(2922 self,2923 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,2924**kw2925):2926super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)29272928# The recipients for various types of notification emails, as2929# RFC 2822 email addresses separated by commas (or the empty2930# string if no recipients are configured). Although there is2931# a mechanism to choose the recipient lists based on on the2932# actual *contents* of the change being reported, we only2933# choose based on the *type* of the change. Therefore we can2934# compute them once and for all:2935 self.__refchange_recipients = refchange_recipients2936 self.__announce_recipients = announce_recipients2937 self.__revision_recipients = revision_recipients29382939defcheck(self):2940if not(self.get_refchange_recipients(None)or2941 self.get_announce_recipients(None)or2942 self.get_revision_recipients(None)or2943 self.get_scancommitforcc()):2944raiseConfigurationException('No email recipients configured!')2945super(StaticRecipientsEnvironmentMixin, self).check()29462947defget_refchange_recipients(self, refchange):2948if self.__refchange_recipients is None:2949returnsuper(StaticRecipientsEnvironmentMixin,2950 self).get_refchange_recipients(refchange)2951return self.__refchange_recipients29522953defget_announce_recipients(self, annotated_tag_change):2954if self.__announce_recipients is None:2955returnsuper(StaticRecipientsEnvironmentMixin,2956 self).get_refchange_recipients(annotated_tag_change)2957return self.__announce_recipients29582959defget_revision_recipients(self, revision):2960if self.__revision_recipients is None:2961returnsuper(StaticRecipientsEnvironmentMixin,2962 self).get_refchange_recipients(revision)2963return self.__revision_recipients296429652966classCLIRecipientsEnvironmentMixin(Environment):2967"""Mixin storing recipients information coming from the2968 command-line."""29692970def__init__(self, cli_recipients=None, **kw):2971super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)2972 self.__cli_recipients = cli_recipients29732974defget_refchange_recipients(self, refchange):2975if self.__cli_recipients is None:2976returnsuper(CLIRecipientsEnvironmentMixin,2977 self).get_refchange_recipients(refchange)2978return self.__cli_recipients29792980defget_announce_recipients(self, annotated_tag_change):2981if self.__cli_recipients is None:2982returnsuper(CLIRecipientsEnvironmentMixin,2983 self).get_announce_recipients(annotated_tag_change)2984return self.__cli_recipients29852986defget_revision_recipients(self, revision):2987if self.__cli_recipients is None:2988returnsuper(CLIRecipientsEnvironmentMixin,2989 self).get_revision_recipients(revision)2990return self.__cli_recipients299129922993classConfigRecipientsEnvironmentMixin(2994 ConfigEnvironmentMixin,2995 StaticRecipientsEnvironmentMixin2996):2997"""Determine recipients statically based on config."""29982999def__init__(self, config, **kw):3000super(ConfigRecipientsEnvironmentMixin, self).__init__(3001 config=config,3002 refchange_recipients=self._get_recipients(3003 config,'refchangelist','mailinglist',3004),3005 announce_recipients=self._get_recipients(3006 config,'announcelist','refchangelist','mailinglist',3007),3008 revision_recipients=self._get_recipients(3009 config,'commitlist','mailinglist',3010),3011 scancommitforcc=config.get('scancommitforcc'),3012**kw3013)30143015def_get_recipients(self, config, *names):3016"""Return the recipients for a particular type of message.30173018 Return the list of email addresses to which a particular type3019 of notification email should be sent, by looking at the config3020 value for "multimailhook.$name" for each of names. Use the3021 value from the first name that is configured. The return3022 value is a (possibly empty) string containing RFC 2822 email3023 addresses separated by commas. If no configuration could be3024 found, raise a ConfigurationException."""30253026for name in names:3027 lines = config.get_all(name)3028if lines is not None:3029 lines = [line.strip()for line in lines]3030# Single "none" is a special value equivalen to empty string.3031if lines == ['none']:3032 lines = ['']3033return', '.join(lines)3034else:3035return''303630373038classStaticRefFilterEnvironmentMixin(Environment):3039"""Set branch filter statically based on constructor parameters."""30403041def__init__(self, ref_filter_incl_regex, ref_filter_excl_regex,3042 ref_filter_do_send_regex, ref_filter_dont_send_regex,3043**kw):3044super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)30453046if ref_filter_incl_regex and ref_filter_excl_regex:3047raiseConfigurationException(3048"Cannot specify both a ref inclusion and exclusion regex.")3049 self.__is_inclusion_filter =bool(ref_filter_incl_regex)3050 default_exclude = self.get_default_ref_ignore_regex()3051if ref_filter_incl_regex:3052 ref_filter_regex = ref_filter_incl_regex3053elif ref_filter_excl_regex:3054 ref_filter_regex = ref_filter_excl_regex +'|'+ default_exclude3055else:3056 ref_filter_regex = default_exclude3057try:3058 self.__compiled_regex = re.compile(ref_filter_regex)3059exceptException:3060raiseConfigurationException(3061'Invalid Ref Filter Regex "%s":%s'% (ref_filter_regex, sys.exc_info()[1]))30623063if ref_filter_do_send_regex and ref_filter_dont_send_regex:3064raiseConfigurationException(3065"Cannot specify both a ref doSend and dontSend regex.")3066 self.__is_do_send_filter =bool(ref_filter_do_send_regex)3067if ref_filter_do_send_regex:3068 ref_filter_send_regex = ref_filter_do_send_regex3069elif ref_filter_dont_send_regex:3070 ref_filter_send_regex = ref_filter_dont_send_regex3071else:3072 ref_filter_send_regex ='.*'3073 self.__is_do_send_filter =True3074try:3075 self.__send_compiled_regex = re.compile(ref_filter_send_regex)3076exceptException:3077raiseConfigurationException(3078'Invalid Ref Filter Regex "%s":%s'%3079(ref_filter_send_regex, sys.exc_info()[1]))30803081defget_ref_filter_regex(self, send_filter=False):3082if send_filter:3083return self.__send_compiled_regex, self.__is_do_send_filter3084else:3085return self.__compiled_regex, self.__is_inclusion_filter308630873088classConfigRefFilterEnvironmentMixin(3089 ConfigEnvironmentMixin,3090 StaticRefFilterEnvironmentMixin3091):3092"""Determine branch filtering statically based on config."""30933094def_get_regex(self, config, key):3095"""Get a list of whitespace-separated regex. The refFilter* config3096 variables are multivalued (hence the use of get_all), and we3097 allow each entry to be a whitespace-separated list (hence the3098 split on each line). The whole thing is glued into a single regex."""3099 values = config.get_all(key)3100if values is None:3101return values3102 items = []3103for line in values:3104for i in line.split():3105 items.append(i)3106if items == []:3107return None3108return'|'.join(items)31093110def__init__(self, config, **kw):3111super(ConfigRefFilterEnvironmentMixin, self).__init__(3112 config=config,3113 ref_filter_incl_regex=self._get_regex(config,'refFilterInclusionRegex'),3114 ref_filter_excl_regex=self._get_regex(config,'refFilterExclusionRegex'),3115 ref_filter_do_send_regex=self._get_regex(config,'refFilterDoSendRegex'),3116 ref_filter_dont_send_regex=self._get_regex(config,'refFilterDontSendRegex'),3117**kw3118)311931203121classProjectdescEnvironmentMixin(Environment):3122"""Make a "projectdesc" value available for templates.31233124 By default, it is set to the first line of $GIT_DIR/description3125 (if that file is present and appears to be set meaningfully)."""31263127def__init__(self, **kw):3128super(ProjectdescEnvironmentMixin, self).__init__(**kw)3129 self.COMPUTED_KEYS += ['projectdesc']31303131defget_projectdesc(self):3132"""Return a one-line descripition of the project."""31333134 git_dir =get_git_dir()3135try:3136 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()3137if projectdesc and not projectdesc.startswith('Unnamed repository'):3138return projectdesc3139exceptIOError:3140pass31413142return'UNNAMED PROJECT'314331443145classGenericEnvironmentMixin(Environment):3146defget_pusher(self):3147return self.osenv.get('USER', self.osenv.get('USERNAME','unknown user'))314831493150classGitoliteEnvironmentHighPrecMixin(Environment):3151defget_pusher(self):3152return self.osenv.get('GL_USER','unknown user')315331543155classGitoliteEnvironmentLowPrecMixin(Environment):3156defget_repo_shortname(self):3157# The gitolite environment variable $GL_REPO is a pretty good3158# repo_shortname (though it's probably not as good as a value3159# the user might have explicitly put in his config).3160return(3161 self.osenv.get('GL_REPO',None)or3162super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()3163)31643165defget_fromaddr(self, change=None):3166 GL_USER = self.osenv.get('GL_USER')3167if GL_USER is not None:3168# Find the path to gitolite.conf. Note that gitolite v33169# did away with the GL_ADMINDIR and GL_CONF environment3170# variables (they are now hard-coded).3171 GL_ADMINDIR = self.osenv.get(3172'GL_ADMINDIR',3173 os.path.expanduser(os.path.join('~','.gitolite')))3174 GL_CONF = self.osenv.get(3175'GL_CONF',3176 os.path.join(GL_ADMINDIR,'conf','gitolite.conf'))3177if os.path.isfile(GL_CONF):3178 f =open(GL_CONF,'rU')3179try:3180 in_user_emails_section =False3181 re_template = r'^\s*#\s*%s\s*$'3182 re_begin, re_user, re_end = (3183 re.compile(re_template % x)3184for x in(3185 r'BEGIN\s+USER\s+EMAILS',3186 re.escape(GL_USER) + r'\s+(.*)',3187 r'END\s+USER\s+EMAILS',3188))3189for l in f:3190 l = l.rstrip('\n')3191if not in_user_emails_section:3192if re_begin.match(l):3193 in_user_emails_section =True3194continue3195if re_end.match(l):3196break3197 m = re_user.match(l)3198if m:3199return m.group(1)3200finally:3201 f.close()3202returnsuper(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)320332043205classIncrementalDateTime(object):3206"""Simple wrapper to give incremental date/times.32073208 Each call will result in a date/time a second later than the3209 previous call. This can be used to falsify email headers, to3210 increase the likelihood that email clients sort the emails3211 correctly."""32123213def__init__(self):3214 self.time = time.time()3215 self.next = self.__next__# Python 2 backward compatibility32163217def__next__(self):3218 formatted =formatdate(self.time,True)3219 self.time +=13220return formatted322132223223classStashEnvironmentHighPrecMixin(Environment):3224def__init__(self, user=None, repo=None, **kw):3225super(StashEnvironmentHighPrecMixin,3226 self).__init__(user=user, repo=repo, **kw)3227 self.__user = user3228 self.__repo = repo32293230defget_pusher(self):3231return re.match('(.*?)\s*<', self.__user).group(1)32323233defget_pusher_email(self):3234return self.__user323532363237classStashEnvironmentLowPrecMixin(Environment):3238def__init__(self, user=None, repo=None, **kw):3239super(StashEnvironmentLowPrecMixin, self).__init__(**kw)3240 self.__repo = repo3241 self.__user = user32423243defget_repo_shortname(self):3244return self.__repo32453246defget_fromaddr(self, change=None):3247return self.__user324832493250classGerritEnvironmentHighPrecMixin(Environment):3251def__init__(self, project=None, submitter=None, update_method=None, **kw):3252super(GerritEnvironmentHighPrecMixin,3253 self).__init__(submitter=submitter, project=project, **kw)3254 self.__project = project3255 self.__submitter = submitter3256 self.__update_method = update_method3257"Make an 'update_method' value available for templates."3258 self.COMPUTED_KEYS += ['update_method']32593260defget_pusher(self):3261if self.__submitter:3262if self.__submitter.find('<') != -1:3263# Submitter has a configured email, we transformed3264# __submitter into an RFC 2822 string already.3265return re.match('(.*?)\s*<', self.__submitter).group(1)3266else:3267# Submitter has no configured email, it's just his name.3268return self.__submitter3269else:3270# If we arrive here, this means someone pushed "Submit" from3271# the gerrit web UI for the CR (or used one of the programmatic3272# APIs to do the same, such as gerrit review) and the3273# merge/push was done by the Gerrit user. It was technically3274# triggered by someone else, but sadly we have no way of3275# determining who that someone else is at this point.3276return'Gerrit'# 'unknown user'?32773278defget_pusher_email(self):3279if self.__submitter:3280return self.__submitter3281else:3282returnsuper(GerritEnvironmentHighPrecMixin, self).get_pusher_email()32833284defget_default_ref_ignore_regex(self):3285 default =super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex()3286return default +'|^refs/changes/|^refs/cache-automerge/|^refs/meta/'32873288defget_revision_recipients(self, revision):3289# Merge commits created by Gerrit when users hit "Submit this patchset"3290# in the Web UI (or do equivalently with REST APIs or the gerrit review3291# command) are not something users want to see an individual email for.3292# Filter them out.3293 committer =read_git_output(['log','--no-walk','--format=%cN',3294 revision.rev.sha1])3295if committer =='Gerrit Code Review':3296return[]3297else:3298returnsuper(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision)32993300defget_update_method(self):3301return self.__update_method330233033304classGerritEnvironmentLowPrecMixin(Environment):3305def__init__(self, project=None, submitter=None, **kw):3306super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)3307 self.__project = project3308 self.__submitter = submitter33093310defget_repo_shortname(self):3311return self.__project33123313defget_fromaddr(self, change=None):3314if self.__submitter and self.__submitter.find('<') != -1:3315return self.__submitter3316else:3317returnsuper(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change)331833193320classPush(object):3321"""Represent an entire push (i.e., a group of ReferenceChanges).33223323 It is easy to figure out what commits were added to a *branch* by3324 a Reference change:33253326 git rev-list change.old..change.new33273328 or removed from a *branch*:33293330 git rev-list change.new..change.old33313332 But it is not quite so trivial to determine which entirely new3333 commits were added to the *repository* by a push and which old3334 commits were discarded by a push. A big part of the job of this3335 class is to figure out these things, and to make sure that new3336 commits are only detailed once even if they were added to multiple3337 references.33383339 The first step is to determine the "other" references--those3340 unaffected by the current push. They are computed by listing all3341 references then removing any affected by this push. The results3342 are stored in Push._other_ref_sha1s.33433344 The commits contained in the repository before this push were33453346 git rev-list other1 other2 other3 ... change1.old change2.old ...33473348 Where "changeN.old" is the old value of one of the references3349 affected by this push.33503351 The commits contained in the repository after this push are33523353 git rev-list other1 other2 other3 ... change1.new change2.new ...33543355 The commits added by this push are the difference between these3356 two sets, which can be written33573358 git rev-list \3359 ^other1 ^other2 ... \3360 ^change1.old ^change2.old ... \3361 change1.new change2.new ...33623363 The commits removed by this push can be computed by33643365 git rev-list \3366 ^other1 ^other2 ... \3367 ^change1.new ^change2.new ... \3368 change1.old change2.old ...33693370 The last point is that it is possible that other pushes are3371 occurring simultaneously to this one, so reference values can3372 change at any time. It is impossible to eliminate all race3373 conditions, but we reduce the window of time during which problems3374 can occur by translating reference names to SHA1s as soon as3375 possible and working with SHA1s thereafter (because SHA1s are3376 immutable)."""33773378# A map {(changeclass, changetype): integer} specifying the order3379# that reference changes will be processed if multiple reference3380# changes are included in a single push. The order is significant3381# mostly because new commit notifications are threaded together3382# with the first reference change that includes the commit. The3383# following order thus causes commits to be grouped with branch3384# changes (as opposed to tag changes) if possible.3385 SORT_ORDER =dict(3386(value, i)for(i, value)inenumerate([3387(BranchChange,'update'),3388(BranchChange,'create'),3389(AnnotatedTagChange,'update'),3390(AnnotatedTagChange,'create'),3391(NonAnnotatedTagChange,'update'),3392(NonAnnotatedTagChange,'create'),3393(BranchChange,'delete'),3394(AnnotatedTagChange,'delete'),3395(NonAnnotatedTagChange,'delete'),3396(OtherReferenceChange,'update'),3397(OtherReferenceChange,'create'),3398(OtherReferenceChange,'delete'),3399])3400)34013402def__init__(self, environment, changes, ignore_other_refs=False):3403 self.changes =sorted(changes, key=self._sort_key)3404 self.__other_ref_sha1s =None3405 self.__cached_commits_spec = {}3406 self.environment = environment34073408if ignore_other_refs:3409 self.__other_ref_sha1s =set()34103411@classmethod3412def_sort_key(klass, change):3413return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)34143415@property3416def_other_ref_sha1s(self):3417"""The GitObjects referred to by references unaffected by this push.3418 """3419if self.__other_ref_sha1s is None:3420# The refnames being changed by this push:3421 updated_refs =set(3422 change.refname3423for change in self.changes3424)34253426# The SHA-1s of commits referred to by all references in this3427# repository *except* updated_refs:3428 sha1s =set()3429 fmt = (3430'%(objectname) %(objecttype) %(refname)\n'3431'%(*objectname) %(*objecttype)%(refname)'3432)3433 ref_filter_regex, is_inclusion_filter = \3434 self.environment.get_ref_filter_regex()3435for line inread_git_lines(3436['for-each-ref','--format=%s'% (fmt,)]):3437(sha1,type, name) = line.split(' ',2)3438if(sha1 andtype=='commit'and3439 name not in updated_refs and3440include_ref(name, ref_filter_regex, is_inclusion_filter)):3441 sha1s.add(sha1)34423443 self.__other_ref_sha1s = sha1s34443445return self.__other_ref_sha1s34463447def_get_commits_spec_incl(self, new_or_old, reference_change=None):3448"""Get new or old SHA-1 from one or each of the changed refs.34493450 Return a list of SHA-1 commit identifier strings suitable as3451 arguments to 'git rev-list' (or 'git log' or ...). The3452 returned identifiers are either the old or new values from one3453 or all of the changed references, depending on the values of3454 new_or_old and reference_change.34553456 new_or_old is either the string 'new' or the string 'old'. If3457 'new', the returned SHA-1 identifiers are the new values from3458 each changed reference. If 'old', the SHA-1 identifiers are3459 the old values from each changed reference.34603461 If reference_change is specified and not None, only the new or3462 old reference from the specified reference is included in the3463 return value.34643465 This function returns None if there are no matching revisions3466 (e.g., because a branch was deleted and new_or_old is 'new').3467 """34683469if not reference_change:3470 incl_spec =sorted(3471getattr(change, new_or_old).sha13472for change in self.changes3473ifgetattr(change, new_or_old)3474)3475if not incl_spec:3476 incl_spec =None3477elif notgetattr(reference_change, new_or_old).commit_sha1:3478 incl_spec =None3479else:3480 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]3481return incl_spec34823483def_get_commits_spec_excl(self, new_or_old):3484"""Get exclusion revisions for determining new or discarded commits.34853486 Return a list of strings suitable as arguments to 'git3487 rev-list' (or 'git log' or ...) that will exclude all3488 commits that, depending on the value of new_or_old, were3489 either previously in the repository (useful for determining3490 which commits are new to the repository) or currently in the3491 repository (useful for determining which commits were3492 discarded from the repository).34933494 new_or_old is either the string 'new' or the string 'old'. If3495 'new', the commits to be excluded are those that were in the3496 repository before the push. If 'old', the commits to be3497 excluded are those that are currently in the repository. """34983499 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]3500 excl_revs = self._other_ref_sha1s.union(3501getattr(change, old_or_new).sha13502for change in self.changes3503ifgetattr(change, old_or_new).typein['commit','tag']3504)3505return['^'+ sha1 for sha1 insorted(excl_revs)]35063507defget_commits_spec(self, new_or_old, reference_change=None):3508"""Get rev-list arguments for added or discarded commits.35093510 Return a list of strings suitable as arguments to 'git3511 rev-list' (or 'git log' or ...) that select those commits3512 that, depending on the value of new_or_old, are either new to3513 the repository or were discarded from the repository.35143515 new_or_old is either the string 'new' or the string 'old'. If3516 'new', the returned list is used to select commits that are3517 new to the repository. If 'old', the returned value is used3518 to select the commits that have been discarded from the3519 repository.35203521 If reference_change is specified and not None, the new or3522 discarded commits are limited to those that are reachable from3523 the new or old value of the specified reference.35243525 This function returns None if there are no added (or discarded)3526 revisions.3527 """3528 key = (new_or_old, reference_change)3529if key not in self.__cached_commits_spec:3530 ret = self._get_commits_spec_incl(new_or_old, reference_change)3531if ret is not None:3532 ret.extend(self._get_commits_spec_excl(new_or_old))3533 self.__cached_commits_spec[key] = ret3534return self.__cached_commits_spec[key]35353536defget_new_commits(self, reference_change=None):3537"""Return a list of commits added by this push.35383539 Return a list of the object names of commits that were added3540 by the part of this push represented by reference_change. If3541 reference_change is None, then return a list of *all* commits3542 added by this push."""35433544 spec = self.get_commits_spec('new', reference_change)3545returngit_rev_list(spec)35463547defget_discarded_commits(self, reference_change):3548"""Return a list of commits discarded by this push.35493550 Return a list of the object names of commits that were3551 entirely discarded from the repository by the part of this3552 push represented by reference_change."""35533554 spec = self.get_commits_spec('old', reference_change)3555returngit_rev_list(spec)35563557defsend_emails(self, mailer, body_filter=None):3558"""Use send all of the notification emails needed for this push.35593560 Use send all of the notification emails (including reference3561 change emails and commit emails) needed for this push. Send3562 the emails using mailer. If body_filter is not None, then use3563 it to filter the lines that are intended for the email3564 body."""35653566# The sha1s of commits that were introduced by this push.3567# They will be removed from this set as they are processed, to3568# guarantee that one (and only one) email is generated for3569# each new commit.3570 unhandled_sha1s =set(self.get_new_commits())3571 send_date =IncrementalDateTime()3572for change in self.changes:3573 sha1s = []3574for sha1 inreversed(list(self.get_new_commits(change))):3575if sha1 in unhandled_sha1s:3576 sha1s.append(sha1)3577 unhandled_sha1s.remove(sha1)35783579# Check if we've got anyone to send to3580if not change.recipients:3581 change.environment.log_warning(3582'*** no recipients configured so no email will be sent\n'3583'*** for%rupdate%s->%s'3584% (change.refname, change.old.sha1, change.new.sha1,)3585)3586else:3587if not change.environment.quiet:3588 change.environment.log_msg(3589'Sending notification emails to:%s'% (change.recipients,))3590 extra_values = {'send_date': next(send_date)}35913592 rev = change.send_single_combined_email(sha1s)3593if rev:3594 mailer.send(3595 change.generate_combined_email(self, rev, body_filter, extra_values),3596 rev.recipients,3597)3598# This change is now fully handled; no need to handle3599# individual revisions any further.3600continue3601else:3602 mailer.send(3603 change.generate_email(self, body_filter, extra_values),3604 change.recipients,3605)36063607 max_emails = change.environment.maxcommitemails3608if max_emails andlen(sha1s) > max_emails:3609 change.environment.log_warning(3610'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s) +3611'*** Try setting multimailhook.maxCommitEmails to a greater value\n'+3612'*** Currently, multimailhook.maxCommitEmails=%d'% max_emails3613)3614return36153616for(num, sha1)inenumerate(sha1s):3617 rev =Revision(change,GitObject(sha1), num=num +1, tot=len(sha1s))3618if not rev.recipients and rev.cc_recipients:3619 change.environment.log_msg('*** Replacing Cc: with To:')3620 rev.recipients = rev.cc_recipients3621 rev.cc_recipients =None3622if rev.recipients:3623 extra_values = {'send_date': next(send_date)}3624 mailer.send(3625 rev.generate_email(self, body_filter, extra_values),3626 rev.recipients,3627)36283629# Consistency check:3630if unhandled_sha1s:3631 change.environment.log_error(3632'ERROR: No emails were sent for the following new commits:\n'3633'%s'3634% ('\n'.join(sorted(unhandled_sha1s)),)3635)363636373638definclude_ref(refname, ref_filter_regex, is_inclusion_filter):3639 does_match =bool(ref_filter_regex.search(refname))3640if is_inclusion_filter:3641return does_match3642else:# exclusion filter -- we include the ref if the regex doesn't match3643return not does_match364436453646defrun_as_post_receive_hook(environment, mailer):3647 environment.check()3648 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)3649 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)3650 changes = []3651while True:3652 line =read_line(sys.stdin)3653if line =='':3654break3655(oldrev, newrev, refname) = line.strip().split(' ',2)3656 environment.get_logger().debug(3657"run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s"%3658(oldrev, newrev, refname))36593660if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3661continue3662if notinclude_ref(refname, send_filter_regex, send_is_inclusion_filter):3663continue3664 changes.append(3665 ReferenceChange.create(environment, oldrev, newrev, refname)3666)3667if changes:3668 push =Push(environment, changes)3669 push.send_emails(mailer, body_filter=environment.filter_body)3670ifhasattr(mailer,'__del__'):3671 mailer.__del__()367236733674defrun_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):3675 environment.check()3676 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)3677 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)3678if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3679return3680if notinclude_ref(refname, send_filter_regex, send_is_inclusion_filter):3681return3682 changes = [3683 ReferenceChange.create(3684 environment,3685read_git_output(['rev-parse','--verify', oldrev]),3686read_git_output(['rev-parse','--verify', newrev]),3687 refname,3688),3689]3690 push =Push(environment, changes, force_send)3691 push.send_emails(mailer, body_filter=environment.filter_body)3692ifhasattr(mailer,'__del__'):3693 mailer.__del__()369436953696defcheck_ref_filter(environment):3697 send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True)3698 ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False)36993700definc_exc_lusion(b):3701if b:3702return'inclusion'3703else:3704return'exclusion'37053706if send_filter_regex:3707 sys.stdout.write("DoSend/DontSend filter regex ("+3708(inc_exc_lusion(send_is_inclusion)) +3709'): '+ send_filter_regex.pattern +3710'\n')3711if send_filter_regex:3712 sys.stdout.write("Include/Exclude filter regex ("+3713(inc_exc_lusion(ref_is_inclusion)) +3714'): '+ ref_filter_regex.pattern +3715'\n')3716 sys.stdout.write(os.linesep)37173718 sys.stdout.write(3719"Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"3720"or refFilterExclusionRegex. No emails will be sent for commits included\n"3721"in these refs.\n"3722"Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"3723"refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"3724"refFilterExclusionRegex. Emails will be sent for commits included in these\n"3725"refs only when the commit reaches a ref which isn't excluded.\n"3726"Refs marked as DO-SEND are not excluded by any filter. Emails will\n"3727"be sent normally for commits included in these refs.\n")37283729 sys.stdout.write(os.linesep)37303731for refname inread_git_lines(['for-each-ref','--format','%(refname)']):3732 sys.stdout.write(refname)3733if notinclude_ref(refname, ref_filter_regex, ref_is_inclusion):3734 sys.stdout.write(' EXCLUDE')3735elif notinclude_ref(refname, send_filter_regex, send_is_inclusion):3736 sys.stdout.write(' DONT-SEND')3737else:3738 sys.stdout.write(' DO-SEND')37393740 sys.stdout.write(os.linesep)374137423743defshow_env(environment, out):3744 out.write('Environment values:\n')3745for(k, v)insorted(environment.get_values().items()):3746if k:# Don't show the {'' : ''} pair.3747 out.write('%s:%r\n'% (k, v))3748 out.write('\n')3749# Flush to avoid interleaving with further log output3750 out.flush()375137523753defcheck_setup(environment):3754 environment.check()3755show_env(environment, sys.stdout)3756 sys.stdout.write("Now, checking that git-multimail's standard input "3757"is properly set ..."+ os.linesep)3758 sys.stdout.write("Please type some text and then press Return"+ os.linesep)3759 stdin = sys.stdin.readline()3760 sys.stdout.write("You have just entered:"+ os.linesep)3761 sys.stdout.write(stdin)3762 sys.stdout.write("git-multimail seems properly set up."+ os.linesep)376337643765defchoose_mailer(config, environment):3766 mailer = config.get('mailer', default='sendmail')37673768if mailer =='smtp':3769 smtpserver = config.get('smtpserver', default='localhost')3770 smtpservertimeout =float(config.get('smtpservertimeout', default=10.0))3771 smtpserverdebuglevel =int(config.get('smtpserverdebuglevel', default=0))3772 smtpencryption = config.get('smtpencryption', default='none')3773 smtpuser = config.get('smtpuser', default='')3774 smtppass = config.get('smtppass', default='')3775 smtpcacerts = config.get('smtpcacerts', default='')3776 mailer =SMTPMailer(3777 environment,3778 envelopesender=(environment.get_sender()or environment.get_fromaddr()),3779 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,3780 smtpserverdebuglevel=smtpserverdebuglevel,3781 smtpencryption=smtpencryption,3782 smtpuser=smtpuser,3783 smtppass=smtppass,3784 smtpcacerts=smtpcacerts3785)3786elif mailer =='sendmail':3787 command = config.get('sendmailcommand')3788if command:3789 command = shlex.split(command)3790 mailer =SendMailer(environment,3791 command=command, envelopesender=environment.get_sender())3792else:3793 environment.log_error(3794'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer +3795'please use one of "smtp" or "sendmail".'3796)3797 sys.exit(1)3798return mailer379938003801KNOWN_ENVIRONMENTS = {3802'generic': {'highprec': GenericEnvironmentMixin},3803'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin,3804'lowprec': GitoliteEnvironmentLowPrecMixin},3805'stash': {'highprec': StashEnvironmentHighPrecMixin,3806'lowprec': StashEnvironmentLowPrecMixin},3807'gerrit': {'highprec': GerritEnvironmentHighPrecMixin,3808'lowprec': GerritEnvironmentLowPrecMixin},3809}381038113812defchoose_environment(config, osenv=None, env=None, recipients=None,3813 hook_info=None):3814 env_name =choose_environment_name(config, env, osenv)3815 environment_klass =build_environment_klass(env_name)3816 env =build_environment(environment_klass, env_name, config,3817 osenv, recipients, hook_info)3818return env381938203821defchoose_environment_name(config, env, osenv):3822if not osenv:3823 osenv = os.environ38243825if not env:3826 env = config.get('environment')38273828if not env:3829if'GL_USER'in osenv and'GL_REPO'in osenv:3830 env ='gitolite'3831else:3832 env ='generic'3833return env383438353836COMMON_ENVIRONMENT_MIXINS = [3837 ConfigRecipientsEnvironmentMixin,3838 CLIRecipientsEnvironmentMixin,3839 ConfigRefFilterEnvironmentMixin,3840 ProjectdescEnvironmentMixin,3841 ConfigMaxlinesEnvironmentMixin,3842 ComputeFQDNEnvironmentMixin,3843 ConfigFilterLinesEnvironmentMixin,3844 PusherDomainEnvironmentMixin,3845 ConfigOptionsEnvironmentMixin,3846]384738483849defbuild_environment_klass(env_name):3850if'class'in KNOWN_ENVIRONMENTS[env_name]:3851return KNOWN_ENVIRONMENTS[env_name]['class']38523853 environment_mixins = []3854 known_env = KNOWN_ENVIRONMENTS[env_name]3855if'highprec'in known_env:3856 high_prec_mixin = known_env['highprec']3857 environment_mixins.append(high_prec_mixin)3858 environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS3859if'lowprec'in known_env:3860 low_prec_mixin = known_env['lowprec']3861 environment_mixins.append(low_prec_mixin)3862 environment_mixins.append(Environment)3863 klass_name = env_name.capitalize() +'Environement'3864 environment_klass =type(3865 klass_name,3866tuple(environment_mixins),3867{},3868)3869 KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass3870return environment_klass387138723873GerritEnvironment =build_environment_klass('gerrit')3874StashEnvironment =build_environment_klass('stash')3875GitoliteEnvironment =build_environment_klass('gitolite')3876GenericEnvironment =build_environment_klass('generic')387738783879defbuild_environment(environment_klass, env, config,3880 osenv, recipients, hook_info):3881 environment_kw = {3882'osenv': osenv,3883'config': config,3884}38853886if env =='stash':3887 environment_kw['user'] = hook_info['stash_user']3888 environment_kw['repo'] = hook_info['stash_repo']3889elif env =='gerrit':3890 environment_kw['project'] = hook_info['project']3891 environment_kw['submitter'] = hook_info['submitter']3892 environment_kw['update_method'] = hook_info['update_method']38933894 environment_kw['cli_recipients'] = recipients38953896returnenvironment_klass(**environment_kw)389738983899defget_version():3900 oldcwd = os.getcwd()3901try:3902try:3903 os.chdir(os.path.dirname(os.path.realpath(__file__)))3904 git_version =read_git_output(['describe','--tags','HEAD'])3905if git_version == __version__:3906return git_version3907else:3908return'%s(%s)'% (__version__, git_version)3909except:3910pass3911finally:3912 os.chdir(oldcwd)3913return __version__391439153916defcompute_gerrit_options(options, args, required_gerrit_options,3917 raw_refname):3918if None in required_gerrit_options:3919raiseSystemExit("Error: Specify all of --oldrev, --newrev, --refname, "3920"and --project; or none of them.")39213922if options.environment not in(None,'gerrit'):3923raiseSystemExit("Non-gerrit environments incompatible with --oldrev, "3924"--newrev, --refname, and --project")3925 options.environment ='gerrit'39263927if args:3928raiseSystemExit("Error: Positional parameters not allowed with "3929"--oldrev, --newrev, and --refname.")39303931# Gerrit oddly omits 'refs/heads/' in the refname when calling3932# ref-updated hook; put it back.3933 git_dir =get_git_dir()3934if(not os.path.exists(os.path.join(git_dir, raw_refname))and3935 os.path.exists(os.path.join(git_dir,'refs','heads',3936 raw_refname))):3937 options.refname ='refs/heads/'+ options.refname39383939# New revisions can appear in a gerrit repository either due to someone3940# pushing directly (in which case options.submitter will be set), or they3941# can press "Submit this patchset" in the web UI for some CR (in which3942# case options.submitter will not be set and gerrit will not have provided3943# us the information about who pressed the button).3944#3945# Note for the nit-picky: I'm lumping in REST API calls and the ssh3946# gerrit review command in with "Submit this patchset" button, since they3947# have the same effect.3948if options.submitter:3949 update_method ='pushed'3950# The submitter argument is almost an RFC 2822 email address; change it3951# from 'User Name (email@domain)' to 'User Name <email@domain>' so it is3952 options.submitter = options.submitter.replace('(','<').replace(')','>')3953else:3954 update_method ='submitted'3955# Gerrit knew who submitted this patchset, but threw that information3956# away when it invoked this hook. However, *IF* Gerrit created a3957# merge to bring the patchset in (project 'Submit Type' is either3958# "Always Merge", or is "Merge if Necessary" and happens to be3959# necessary for this particular CR), then it will have the committer3960# of that merge be 'Gerrit Code Review' and the author will be the3961# person who requested the submission of the CR. Since this is fairly3962# likely for most gerrit installations (of a reasonable size), it's3963# worth the extra effort to try to determine the actual submitter.3964 rev_info =read_git_lines(['log','--no-walk','--merges',3965'--format=%cN%n%aN <%aE>', options.newrev])3966if rev_info and rev_info[0] =='Gerrit Code Review':3967 options.submitter = rev_info[1]39683969# We pass back refname, oldrev, newrev as args because then the3970# gerrit ref-updated hook is much like the git update hook3971return(options,3972[options.refname, options.oldrev, options.newrev],3973{'project': options.project,'submitter': options.submitter,3974'update_method': update_method})397539763977defcheck_hook_specific_args(options, args):3978 raw_refname = options.refname3979# Convert each string option unicode for Python3.3980if PYTHON3:3981 opts = ['environment','recipients','oldrev','newrev','refname',3982'project','submitter','stash_user','stash_repo']3983for opt in opts:3984if nothasattr(options, opt):3985continue3986 obj =getattr(options, opt)3987if obj:3988 enc = obj.encode('utf-8','surrogateescape')3989 dec = enc.decode('utf-8','replace')3990setattr(options, opt, dec)39913992# First check for stash arguments3993if(options.stash_user is None) != (options.stash_repo is None):3994raiseSystemExit("Error: Specify both of --stash-user and "3995"--stash-repo or neither.")3996if options.stash_user:3997 options.environment ='stash'3998return options, args, {'stash_user': options.stash_user,3999'stash_repo': options.stash_repo}40004001# Finally, check for gerrit specific arguments4002 required_gerrit_options = (options.oldrev, options.newrev, options.refname,4003 options.project)4004if required_gerrit_options != (None,) *4:4005returncompute_gerrit_options(options, args, required_gerrit_options,4006 raw_refname)40074008# No special options in use, just return what we started with4009return options, args, {}401040114012classLogger(object):4013defparse_verbose(self, verbose):4014if verbose >0:4015return logging.DEBUG4016else:4017return logging.INFO40184019defcreate_log_file(self, environment, name, path, verbosity):4020 log_file = logging.getLogger(name)4021 file_handler = logging.FileHandler(path)4022 log_fmt = logging.Formatter("%(asctime)s[%(levelname)-5.5s]%(message)s")4023 file_handler.setFormatter(log_fmt)4024 log_file.addHandler(file_handler)4025 log_file.setLevel(verbosity)4026return log_file40274028def__init__(self, environment):4029 self.environment = environment4030 self.loggers = []4031 stderr_log = logging.getLogger('git_multimail.stderr')40324033classEncodedStderr(object):4034defwrite(self, x):4035write_str(sys.stderr, x)40364037defflush(self):4038 sys.stderr.flush()40394040 stderr_handler = logging.StreamHandler(EncodedStderr())4041 stderr_log.addHandler(stderr_handler)4042 stderr_log.setLevel(self.parse_verbose(environment.verbose))4043 self.loggers.append(stderr_log)40444045if environment.debug_log_file is not None:4046 debug_log_file = self.create_log_file(4047 environment,'git_multimail.debug', environment.debug_log_file, logging.DEBUG)4048 self.loggers.append(debug_log_file)40494050if environment.log_file is not None:4051 log_file = self.create_log_file(4052 environment,'git_multimail.file', environment.log_file, logging.INFO)4053 self.loggers.append(log_file)40544055if environment.error_log_file is not None:4056 error_log_file = self.create_log_file(4057 environment,'git_multimail.error', environment.error_log_file, logging.ERROR)4058 self.loggers.append(error_log_file)40594060definfo(self, msg):4061for l in self.loggers:4062 l.info(msg)40634064defdebug(self, msg):4065for l in self.loggers:4066 l.debug(msg)40674068defwarning(self, msg):4069for l in self.loggers:4070 l.warning(msg)40714072deferror(self, msg):4073for l in self.loggers:4074 l.error(msg)407540764077defmain(args):4078 parser = optparse.OptionParser(4079 description=__doc__,4080 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',4081)40824083 parser.add_option(4084'--environment','--env', action='store',type='choice',4085 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,4086help=(4087'Choose type of environment is in use. Default is taken from '4088'multimailhook.environment if set; otherwise "generic".'4089),4090)4091 parser.add_option(4092'--stdout', action='store_true', default=False,4093help='Output emails to stdout rather than sending them.',4094)4095 parser.add_option(4096'--recipients', action='store', default=None,4097help='Set list of email recipients for all types of emails.',4098)4099 parser.add_option(4100'--show-env', action='store_true', default=False,4101help=(4102'Write to stderr the values determined for the environment '4103'(intended for debugging purposes), then proceed normally.'4104),4105)4106 parser.add_option(4107'--force-send', action='store_true', default=False,4108help=(4109'Force sending refchange email when using as an update hook. '4110'This is useful to work around the unreliable new commits '4111'detection in this mode.'4112),4113)4114 parser.add_option(4115'-c', metavar="<name>=<value>", action='append',4116help=(4117'Pass a configuration parameter through to git. The value given '4118'will override values from configuration files. See the -c option '4119'of git(1) for more details. (Only works with git >= 1.7.3)'4120),4121)4122 parser.add_option(4123'--version','-v', action='store_true', default=False,4124help=(4125"Display git-multimail's version"4126),4127)41284129 parser.add_option(4130'--python-version', action='store_true', default=False,4131help=(4132"Display the version of Python used by git-multimail"4133),4134)41354136 parser.add_option(4137'--check-ref-filter', action='store_true', default=False,4138help=(4139'List refs and show information on how git-multimail '4140'will process them.'4141)4142)41434144# The following options permit this script to be run as a gerrit4145# ref-updated hook. See e.g.4146# code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt4147# We suppress help for these items, since these are specific to gerrit,4148# and we don't want users directly using them any way other than how the4149# gerrit ref-updated hook is called.4150 parser.add_option('--oldrev', action='store',help=optparse.SUPPRESS_HELP)4151 parser.add_option('--newrev', action='store',help=optparse.SUPPRESS_HELP)4152 parser.add_option('--refname', action='store',help=optparse.SUPPRESS_HELP)4153 parser.add_option('--project', action='store',help=optparse.SUPPRESS_HELP)4154 parser.add_option('--submitter', action='store',help=optparse.SUPPRESS_HELP)41554156# The following allow this to be run as a stash asynchronous post-receive4157# hook (almost identical to a git post-receive hook but triggered also for4158# merges of pull requests from the UI). We suppress help for these items,4159# since these are specific to stash.4160 parser.add_option('--stash-user', action='store',help=optparse.SUPPRESS_HELP)4161 parser.add_option('--stash-repo', action='store',help=optparse.SUPPRESS_HELP)41624163(options, args) = parser.parse_args(args)4164(options, args, hook_info) =check_hook_specific_args(options, args)41654166if options.version:4167 sys.stdout.write('git-multimail version '+get_version() +'\n')4168return41694170if options.python_version:4171 sys.stdout.write('Python version '+ sys.version +'\n')4172return41734174if options.c:4175 Config.add_config_parameters(options.c)41764177 config =Config('multimailhook')41784179 environment =None4180try:4181 environment =choose_environment(4182 config, osenv=os.environ,4183 env=options.environment,4184 recipients=options.recipients,4185 hook_info=hook_info,4186)41874188if options.show_env:4189show_env(environment, sys.stderr)41904191if options.stdout or environment.stdout:4192 mailer =OutputMailer(sys.stdout)4193else:4194 mailer =choose_mailer(config, environment)41954196 must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP')4197if must_check_setup =='':4198 must_check_setup =False4199if options.check_ref_filter:4200check_ref_filter(environment)4201elif must_check_setup:4202check_setup(environment)4203# Dual mode: if arguments were specified on the command line, run4204# like an update hook; otherwise, run as a post-receive hook.4205elif args:4206iflen(args) !=3:4207 parser.error('Need zero or three non-option arguments')4208(refname, oldrev, newrev) = args4209 environment.get_logger().debug(4210"run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s"%4211(refname, oldrev, newrev, options.force_send))4212run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)4213else:4214run_as_post_receive_hook(environment, mailer)4215except ConfigurationException:4216 sys.exit(sys.exc_info()[1])4217exceptSystemExit:4218raise4219exceptException:4220 t, e, tb = sys.exc_info()4221import traceback4222 sys.stderr.write('\n')# Avoid mixing message with previous output4223 msg = (4224'Exception\''+ t.__name__+4225'\'raised. Please report this as a bug to\n'4226'https://github.com/git-multimail/git-multimail/issues\n'4227'with the information below:\n\n'4228'git-multimail version '+get_version() +'\n'4229'Python version '+ sys.version +'\n'+4230 traceback.format_exc())4231try:4232 environment.get_logger().error(msg)4233except:4234 sys.stderr.write(msg)4235 sys.exit(1)42364237if __name__ =='__main__':4238main(sys.argv[1:])