12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597 |
- #!/usr/bin/env python
- # flake8: noqa
- # Copyright (c) 2012-2014 Michael Haggerty and others
- # Derived from contrib/hooks/post-receive-email, which is
- # Copyright (c) 2007 Andy Parkins
- # and also includes contributions by other authors.
- #
- # This file is part of git-multimail.
- #
- # git-multimail is free software: you can redistribute it and/or
- # modify it under the terms of the GNU General Public License version
- # 2 as published by the Free Software Foundation.
- #
- # This program is distributed in the hope that it will be useful, but
- # WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- # General Public License for more details.
- #
- # You should have received a copy of the GNU General Public License
- # along with this program. If not, see
- # <http://www.gnu.org/licenses/>.
- """Generate notification emails for pushes to a git repository.
- This hook sends emails describing changes introduced by pushes to a
- git repository. For each reference that was changed, it emits one
- ReferenceChange email summarizing how the reference was changed,
- followed by one Revision email for each new commit that was introduced
- by the reference change.
- Each commit is announced in exactly one Revision email. If the same
- commit is merged into another branch in the same or a later push, then
- the ReferenceChange email will list the commit's SHA1 and its one-line
- summary, but no new Revision email will be generated.
- This script is designed to be used as a "post-receive" hook in a git
- repository (see githooks(5)). It can also be used as an "update"
- script, but this usage is not completely reliable and is deprecated.
- To help with debugging, this script accepts a --stdout option, which
- causes the emails to be written to standard output rather than sent
- using sendmail.
- See the accompanying README file for the complete documentation.
- """
- from __future__ import print_function, unicode_literals
- import sys
- import os
- import re
- import bisect
- import socket
- import subprocess
- import shlex
- import optparse
- import smtplib
- import time
- import six
- try:
- from email.utils import make_msgid
- from email.utils import getaddresses
- from email.utils import formataddr
- from email.utils import formatdate
- from email.header import Header
- except ImportError:
- # Prior to Python 2.5, the email module used different names:
- from email.Utils import make_msgid
- from email.Utils import getaddresses
- from email.Utils import formataddr
- from email.Utils import formatdate
- from email.Header import Header
- DEBUG = False
- ZEROS = '0' * 40
- LOGBEGIN = '- Log -------------------------------------------------------'\
- '----------\n'
- LOGEND = '---------------------------------------------------------------'\
- '--------\n'
- ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
- # It is assumed in many places that the encoding is uniformly UTF-8,
- # so changing these constants is unsupported. But define them here
- # anyway, to make it easier to find (at least most of) the places
- # where the encoding is important.
- (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
- REF_CREATED_SUBJECT_TEMPLATE = (
- '%(emailprefix)s%(refname_type)s %(short_refname)s created'
- ' (now %(newrev_short)s)'
- )
- REF_UPDATED_SUBJECT_TEMPLATE = (
- '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
- ' (%(oldrev_short)s -> %(newrev_short)s)'
- )
- REF_DELETED_SUBJECT_TEMPLATE = (
- '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
- ' (was %(oldrev_short)s)'
- )
- REFCHANGE_HEADER_TEMPLATE = """\
- Date: %(send_date)s
- To: %(recipients)s
- Subject: %(subject)s
- MIME-Version: 1.0
- Content-Type: text/plain; charset=%(charset)s
- Content-Transfer-Encoding: 8bit
- Message-ID: %(msgid)s
- From: %(fromaddr)s
- Reply-To: %(reply_to)s
- X-Git-Host: %(fqdn)s
- X-Git-Repo: %(repo_shortname)s
- X-Git-Refname: %(refname)s
- X-Git-Reftype: %(refname_type)s
- X-Git-Oldrev: %(oldrev)s
- X-Git-Newrev: %(newrev)s
- Auto-Submitted: auto-generated
- """
- REFCHANGE_INTRO_TEMPLATE = """\
- This is an automated email from the git hooks/post-receive script.
- %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
- in repository %(repo_shortname)s.
- """
- FOOTER_TEMPLATE = """\
- -- \n\
- To stop receiving notification emails like this one, please contact
- %(administrator)s.
- """
- REWIND_ONLY_TEMPLATE = """\
- This update removed existing revisions from the reference, leaving the
- reference pointing at a previous point in the repository history.
- * -- * -- N %(refname)s (%(newrev_short)s)
- \\
- O -- O -- O (%(oldrev_short)s)
- Any revisions marked "omits" are not gone; other references still
- refer to them. Any revisions marked "discards" are gone forever.
- """
- NON_FF_TEMPLATE = """\
- This update added new revisions after undoing existing revisions.
- That is to say, some revisions that were in the old version of the
- %(refname_type)s are not in the new version. This situation occurs
- when a user --force pushes a change and generates a repository
- containing something like this:
- * -- * -- B -- O -- O -- O (%(oldrev_short)s)
- \\
- N -- N -- N %(refname)s (%(newrev_short)s)
- You should already have received notification emails for all of the O
- revisions, and so the following emails describe only the N revisions
- from the common base, B.
- Any revisions marked "omits" are not gone; other references still
- refer to them. Any revisions marked "discards" are gone forever.
- """
- NO_NEW_REVISIONS_TEMPLATE = """\
- No new revisions were added by this update.
- """
- DISCARDED_REVISIONS_TEMPLATE = """\
- This change permanently discards the following revisions:
- """
- NO_DISCARDED_REVISIONS_TEMPLATE = """\
- The revisions that were on this %(refname_type)s are still contained in
- other references; therefore, this change does not discard any commits
- from the repository.
- """
- NEW_REVISIONS_TEMPLATE = """\
- The %(tot)s revisions listed above as "new" are entirely new to this
- repository and will be described in separate emails. The revisions
- listed as "adds" were already present in the repository and have only
- been added to this reference.
- """
- TAG_CREATED_TEMPLATE = """\
- at %(newrev_short)-9s (%(newrev_type)s)
- """
- TAG_UPDATED_TEMPLATE = """\
- *** WARNING: tag %(short_refname)s was modified! ***
- from %(oldrev_short)-9s (%(oldrev_type)s)
- to %(newrev_short)-9s (%(newrev_type)s)
- """
- TAG_DELETED_TEMPLATE = """\
- *** WARNING: tag %(short_refname)s was deleted! ***
- """
- # The template used in summary tables. It looks best if this uses the
- # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
- BRIEF_SUMMARY_TEMPLATE = """\
- %(action)10s %(rev_short)-9s %(text)s
- """
- NON_COMMIT_UPDATE_TEMPLATE = """\
- This is an unusual reference change because the reference did not
- refer to a commit either before or after the change. We do not know
- how to provide full information about this reference change.
- """
- REVISION_HEADER_TEMPLATE = """\
- Date: %(send_date)s
- To: %(recipients)s
- Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
- MIME-Version: 1.0
- Content-Type: text/plain; charset=%(charset)s
- Content-Transfer-Encoding: 8bit
- From: %(fromaddr)s
- Reply-To: %(reply_to)s
- In-Reply-To: %(reply_to_msgid)s
- References: %(reply_to_msgid)s
- X-Git-Host: %(fqdn)s
- X-Git-Repo: %(repo_shortname)s
- X-Git-Refname: %(refname)s
- X-Git-Reftype: %(refname_type)s
- X-Git-Rev: %(rev)s
- Auto-Submitted: auto-generated
- """
- REVISION_INTRO_TEMPLATE = """\
- This is an automated email from the git hooks/post-receive script.
- %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
- in repository %(repo_shortname)s.
- """
- REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
- class CommandError(Exception):
- def __init__(self, cmd, retcode):
- self.cmd = cmd
- self.retcode = retcode
- Exception.__init__(
- self,
- 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
- )
- class ConfigurationException(Exception):
- pass
- # The "git" program (this could be changed to include a full path):
- GIT_EXECUTABLE = 'git'
- # How "git" should be invoked (including global arguments), as a list
- # of words. This variable is usually initialized automatically by
- # read_git_output() via choose_git_command(), but if a value is set
- # here then it will be used unconditionally.
- GIT_CMD = None
- def choose_git_command():
- """Decide how to invoke git, and record the choice in GIT_CMD."""
- global GIT_CMD
- if GIT_CMD is None:
- try:
- # Check to see whether the "-c" option is accepted (it was
- # only added in Git 1.7.2). We don't actually use the
- # output of "git --version", though if we needed more
- # specific version information this would be the place to
- # do it.
- cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
- read_output(cmd)
- GIT_CMD = [
- GIT_EXECUTABLE, '-c',
- 'i18n.logoutputencoding=%s' % (ENCODING,)]
- except CommandError:
- GIT_CMD = [GIT_EXECUTABLE]
- def read_git_output(args, input=None, keepends=False, **kw):
- """Read the output of a Git command."""
- if GIT_CMD is None:
- choose_git_command()
- return read_output(
- GIT_CMD + args, input=input, keepends=keepends, **kw)
- def read_output(cmd, input=None, keepends=False, **kw):
- if input:
- stdin = subprocess.PIPE
- else:
- stdin = None
- p = subprocess.Popen(
- cmd, stdin=stdin, stdout=subprocess.PIPE,
- stderr=subprocess.PIPE, **kw
- )
- (out, err) = p.communicate(input)
- retcode = p.wait()
- if retcode:
- raise CommandError(cmd, retcode)
- if not keepends:
- out = out.rstrip('\n\r')
- return out
- def read_git_lines(args, keepends=False, **kw):
- """Return the lines output by Git command.
- Return as single lines, with newlines stripped off."""
- return read_git_output(args, keepends=True, **kw).splitlines(keepends)
- def header_encode(text, header_name=None):
- """Encode and line-wrap the value of an email header field."""
- try:
- if isinstance(text, str):
- text = text.decode(ENCODING, 'replace')
- return Header(text, header_name=header_name).encode()
- except UnicodeEncodeError:
- return Header(text, header_name=header_name, charset=CHARSET,
- errors='replace').encode()
- def addr_header_encode(text, header_name=None):
- """Encode and line-wrap the value of an email header field containing
- email addresses."""
- return Header(
- ', '.join(
- formataddr((header_encode(name), emailaddr))
- for name, emailaddr in getaddresses([text])
- ),
- header_name=header_name
- ).encode()
- class Config(object):
- def __init__(self, section, git_config=None):
- """Represent a section of the git configuration.
- If git_config is specified, it is passed to "git config" in
- the GIT_CONFIG environment variable, meaning that "git config"
- will read the specified path rather than the Git default
- config paths."""
- self.section = section
- if git_config:
- self.env = os.environ.copy()
- self.env['GIT_CONFIG'] = git_config
- else:
- self.env = None
- @staticmethod
- def _split(s):
- """Split NUL-terminated values."""
- words = s.split('\0')
- assert words[-1] == ''
- return words[:-1]
- def get(self, name, default=None):
- try:
- values = self._split(read_git_output(
- ['config', '--get', '--null', '%s.%s' % (self.section, name)],
- env=self.env, keepends=True,
- ))
- assert len(values) == 1
- return values[0]
- except CommandError:
- return default
- def get_bool(self, name, default=None):
- try:
- value = read_git_output(
- ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
- env=self.env,
- )
- except CommandError:
- return default
- return value == 'true'
- def get_all(self, name, default=None):
- """Read a (possibly multivalued) setting from the configuration.
- Return the result as a list of values, or default if the name
- is unset."""
- try:
- return self._split(read_git_output(
- ['config', '--get-all', '--null', '%s.%s' % (
- self.section, name)],
- env=self.env, keepends=True,
- ))
- except CommandError as e:
- if e.retcode == 1:
- # "the section or key is invalid"; i.e., there is no
- # value for the specified key.
- return default
- else:
- raise
- def get_recipients(self, name, default=None):
- """Read a recipients list from the configuration.
- Return the result as a comma-separated list of email
- addresses, or default if the option is unset. If the setting
- has multiple values, concatenate them with comma separators."""
- lines = self.get_all(name, default=None)
- if lines is None:
- return default
- return ', '.join(line.strip() for line in lines)
- def set(self, name, value):
- read_git_output(
- ['config', '%s.%s' % (self.section, name), value],
- env=self.env,
- )
- def add(self, name, value):
- read_git_output(
- ['config', '--add', '%s.%s' % (self.section, name), value],
- env=self.env,
- )
- def has_key(self, name):
- return self.get_all(name, default=None) is not None
- def unset_all(self, name):
- try:
- read_git_output(
- ['config', '--unset-all', '%s.%s' % (self.section, name)],
- env=self.env,
- )
- except CommandError as e:
- if e.retcode == 5:
- # The name doesn't exist, which is what we wanted anyway...
- pass
- else:
- raise
- def set_recipients(self, name, value):
- self.unset_all(name)
- for pair in getaddresses([value]):
- self.add(name, formataddr(pair))
- def generate_summaries(*log_args):
- """Generate a brief summary for each revision requested.
- log_args are strings that will be passed directly to "git log" as
- revision selectors. Iterate over (sha1_short, subject) for each
- commit specified by log_args (subject is the first line of the
- commit message as a string without EOLs)."""
- cmd = [
- 'log', '--abbrev', '--format=%h %s',
- ] + list(log_args) + ['--']
- for line in read_git_lines(cmd):
- yield tuple(line.split(' ', 1))
- def limit_lines(lines, max_lines):
- for (index, line) in enumerate(lines):
- if index < max_lines:
- yield line
- if index >= max_lines:
- yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
- def limit_linelength(lines, max_linelength):
- for line in lines:
- # Don't forget that lines always include a trailing newline.
- if len(line) > max_linelength + 1:
- line = line[:max_linelength - 7] + ' [...]\n'
- yield line
- class CommitSet(object):
- """A (constant) set of object names.
- The set should be initialized with full SHA1 object names. The
- __contains__() method returns True iff its argument is an
- abbreviation of any the names in the set."""
- def __init__(self, names):
- self._names = sorted(names)
- def __len__(self):
- return len(self._names)
- def __contains__(self, sha1_abbrev):
- """Return True iff this set contains sha1_abbrev (which might
- be abbreviated)."""
- i = bisect.bisect_left(self._names, sha1_abbrev)
- return i < len(self) and self._names[i].startswith(sha1_abbrev)
- @six.python_2_unicode_compatible
- class GitObject(object):
- def __init__(self, sha1, type=None):
- if sha1 == ZEROS:
- self.sha1 = self.type = self.commit_sha1 = None
- else:
- self.sha1 = sha1
- self.type = type or read_git_output(
- ['cat-file', '-t', self.sha1])
- if self.type == 'commit':
- self.commit_sha1 = self.sha1
- elif self.type == 'tag':
- try:
- self.commit_sha1 = read_git_output(
- ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
- )
- except CommandError:
- # Cannot deref tag to determine commit_sha1
- self.commit_sha1 = None
- else:
- self.commit_sha1 = None
- self.short = read_git_output(['rev-parse', '--short', sha1])
- def get_summary(self):
- """Return (sha1_short, subject) for this commit."""
- if not self.sha1:
- raise ValueError('Empty commit has no summary')
- return next(iter(generate_summaries('--no-walk', self.sha1)))
- def __eq__(self, other):
- return isinstance(other, GitObject) and self.sha1 == other.sha1
- def __hash__(self):
- return hash(self.sha1)
- def __bool__(self):
- return bool(self.sha1)
- def __str__(self):
- return self.sha1 or ZEROS
- class Change(object):
- """A Change that has been made to the Git repository.
- Abstract class from which both Revisions and ReferenceChanges are
- derived. A Change knows how to generate a notification email
- describing itself."""
- def __init__(self, environment):
- self.environment = environment
- self._values = None
- def _compute_values(self):
- """Return a dictionary {keyword : expansion} for this Change.
- Derived classes overload this method to add more entries to
- the return value. This method is used internally by
- get_values(). The return value should always be a new
- dictionary."""
- return self.environment.get_values()
- def get_values(self, **extra_values):
- """Return a dictionary {keyword : expansion} for this Change.
- Return a dictionary mapping keywords to the values that they
- should be expanded to for this Change (used when interpolating
- template strings). If any keyword arguments are supplied, add
- those to the return value as well. The return value is always
- a new dictionary."""
- if self._values is None:
- self._values = self._compute_values()
- values = self._values.copy()
- if extra_values:
- values.update(extra_values)
- return values
- def expand(self, template, **extra_values):
- """Expand template.
- Expand the template (which should be a string) using string
- interpolation of the values for this Change. If any keyword
- arguments are provided, also include those in the keywords
- available for interpolation."""
- return template % self.get_values(**extra_values)
- def expand_lines(self, template, **extra_values):
- """Break template into lines and expand each line."""
- values = self.get_values(**extra_values)
- for line in template.splitlines(True):
- yield line % values
- def expand_header_lines(self, template, **extra_values):
- """Break template into lines and expand each line as an RFC 2822
- header.
- Encode values and split up lines that are too long. Silently
- skip lines that contain references to unknown variables."""
- values = self.get_values(**extra_values)
- for line in template.splitlines():
- (name, value) = line.split(':', 1)
- try:
- value = value % values
- except KeyError as e:
- if DEBUG:
- sys.stderr.write(
- 'Warning: unknown variable %r in the following '
- 'line; line skipped:\n'
- ' %s\n'
- % (e.args[0], line,)
- )
- else:
- if name.lower() in ADDR_HEADERS:
- value = addr_header_encode(value, name)
- else:
- value = header_encode(value, name)
- for splitline in (
- '%s: %s\n' % (name, value)).splitlines(True):
- yield splitline
- def generate_email_header(self):
- """Generate the RFC 2822 email headers for this Change, a line at a
- time.
- The output should not include the trailing blank line."""
- raise NotImplementedError()
- def generate_email_intro(self):
- """Generate the email intro for this Change, a line at a time.
- The output will be used as the standard boilerplate at the top
- of the email body."""
- raise NotImplementedError()
- def generate_email_body(self):
- """Generate the main part of the email body, a line at a time.
- The text in the body might be truncated after a specified
- number of lines (see multimailhook.emailmaxlines)."""
- raise NotImplementedError()
- def generate_email_footer(self):
- """Generate the footer of the email, a line at a time.
- The footer is always included, irrespective of
- multimailhook.emailmaxlines."""
- raise NotImplementedError()
- def generate_email(self, push, body_filter=None, extra_header_values={}):
- """Generate an email describing this change.
- Iterate over the lines (including the header lines) of an
- email describing this change. If body_filter is not None,
- then use it to filter the lines that are intended for the
- email body.
- The extra_header_values field is received as a dict and not as
- **kwargs, to allow passing other keyword arguments in the
- future (e.g. passing extra values to generate_email_intro()"""
- for line in self.generate_email_header(**extra_header_values):
- yield line
- yield '\n'
- for line in self.generate_email_intro():
- yield line
- body = self.generate_email_body(push)
- if body_filter is not None:
- body = body_filter(body)
- for line in body:
- yield line
- for line in self.generate_email_footer():
- yield line
- class Revision(Change):
- """A Change consisting of a single git commit."""
- def __init__(self, reference_change, rev, num, tot):
- Change.__init__(self, reference_change.environment)
- self.reference_change = reference_change
- self.rev = rev
- self.change_type = self.reference_change.change_type
- self.refname = self.reference_change.refname
- self.num = num
- self.tot = tot
- self.author = read_git_output(
- ['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
- self.recipients = self.environment.get_revision_recipients(self)
- def _compute_values(self):
- values = Change._compute_values(self)
- oneline = read_git_output(
- ['log', '--format=%s', '--no-walk', self.rev.sha1]
- )
- values['rev'] = self.rev.sha1
- values['rev_short'] = self.rev.short
- values['change_type'] = self.change_type
- values['refname'] = self.refname
- values['short_refname'] = self.reference_change.short_refname
- values['refname_type'] = self.reference_change.refname_type
- values['reply_to_msgid'] = self.reference_change.msgid
- values['num'] = self.num
- values['tot'] = self.tot
- values['recipients'] = self.recipients
- values['oneline'] = oneline
- values['author'] = self.author
- reply_to = self.environment.get_reply_to_commit(self)
- if reply_to:
- values['reply_to'] = reply_to
- return values
- def generate_email_header(self, **extra_values):
- for line in self.expand_header_lines(
- REVISION_HEADER_TEMPLATE, **extra_values):
- yield line
- def generate_email_intro(self):
- for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
- yield line
- def generate_email_body(self, push):
- """Show this revision."""
- return read_git_lines(
- ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
- keepends=True,
- )
- def generate_email_footer(self):
- return self.expand_lines(REVISION_FOOTER_TEMPLATE)
- class ReferenceChange(Change):
- """A Change to a Git reference.
- An abstract class representing a create, update, or delete of a
- Git reference. Derived classes handle specific types of reference
- (e.g., tags vs. branches). These classes generate the main
- reference change email summarizing the reference change and
- whether it caused any any commits to be added or removed.
- ReferenceChange objects are usually created using the static
- create() method, which has the logic to decide which derived class
- to instantiate."""
- REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
- @staticmethod
- def create(environment, oldrev, newrev, refname):
- """Return a ReferenceChange object representing the change.
- Return an object that represents the type of change that is being
- made. oldrev and newrev should be SHA1s or ZEROS."""
- old = GitObject(oldrev)
- new = GitObject(newrev)
- rev = new or old
- # The revision type tells us what type the commit is, combined with
- # the location of the ref we can decide between
- # - working branch
- # - tracking branch
- # - unannotated tag
- # - annotated tag
- m = ReferenceChange.REF_RE.match(refname)
- if m:
- area = m.group('area')
- short_refname = m.group('shortname')
- else:
- area = ''
- short_refname = refname
- if rev.type == 'tag':
- # Annotated tag:
- klass = AnnotatedTagChange
- elif rev.type == 'commit':
- if area == 'tags':
- # Non-annotated tag:
- klass = NonAnnotatedTagChange
- elif area == 'heads':
- # Branch:
- klass = BranchChange
- elif area == 'remotes':
- # Tracking branch:
- sys.stderr.write(
- '*** Push-update of tracking branch %r\n'
- '*** - incomplete email generated.\n' % (refname,)
- )
- klass = OtherReferenceChange
- else:
- # Some other reference namespace:
- sys.stderr.write(
- '*** Push-update of strange reference %r\n'
- '*** - incomplete email generated.\n' % (refname,)
- )
- klass = OtherReferenceChange
- else:
- # Anything else (is there anything else?)
- sys.stderr.write(
- '*** Unknown type of update to %r (%s)\n'
- '*** - incomplete email generated.\n' % (refname, rev.type,)
- )
- klass = OtherReferenceChange
- return klass(
- environment,
- refname=refname, short_refname=short_refname,
- old=old, new=new, rev=rev,
- )
- def __init__(self, environment, refname, short_refname, old, new, rev):
- Change.__init__(self, environment)
- self.change_type = {
- (False, True): 'create',
- (True, True): 'update',
- (True, False): 'delete',
- }[bool(old), bool(new)]
- self.refname = refname
- self.short_refname = short_refname
- self.old = old
- self.new = new
- self.rev = rev
- self.msgid = make_msgid()
- self.diffopts = environment.diffopts
- self.logopts = environment.logopts
- self.commitlogopts = environment.commitlogopts
- self.showlog = environment.refchange_showlog
- def _compute_values(self):
- values = Change._compute_values(self)
- values['change_type'] = self.change_type
- values['refname_type'] = self.refname_type
- values['refname'] = self.refname
- values['short_refname'] = self.short_refname
- values['msgid'] = self.msgid
- values['recipients'] = self.recipients
- values['oldrev'] = "%s" % self.old
- values['oldrev_short'] = self.old.short
- values['newrev'] = "%s" % self.new
- values['newrev_short'] = self.new.short
- if self.old:
- values['oldrev_type'] = self.old.type
- if self.new:
- values['newrev_type'] = self.new.type
- reply_to = self.environment.get_reply_to_refchange(self)
- if reply_to:
- values['reply_to'] = reply_to
- return values
- def get_subject(self):
- template = {
- 'create': REF_CREATED_SUBJECT_TEMPLATE,
- 'update': REF_UPDATED_SUBJECT_TEMPLATE,
- 'delete': REF_DELETED_SUBJECT_TEMPLATE,
- }[self.change_type]
- return self.expand(template)
- def generate_email_header(self, **extra_values):
- if 'subject' not in extra_values:
- extra_values['subject'] = self.get_subject()
- for line in self.expand_header_lines(
- REFCHANGE_HEADER_TEMPLATE, **extra_values):
- yield line
- def generate_email_intro(self):
- for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
- yield line
- def generate_email_body(self, push):
- """Call the appropriate body-generation routine.
- Call one of generate_create_summary() /
- generate_update_summary() / generate_delete_summary()."""
- change_summary = {
- 'create': self.generate_create_summary,
- 'delete': self.generate_delete_summary,
- 'update': self.generate_update_summary,
- }[self.change_type](push)
- for line in change_summary:
- yield line
- for line in self.generate_revision_change_summary(push):
- yield line
- def generate_email_footer(self):
- return self.expand_lines(FOOTER_TEMPLATE)
- def generate_revision_change_log(self, new_commits_list):
- if self.showlog:
- yield '\n'
- yield 'Detailed log of new commits:\n\n'
- for line in read_git_lines(
- ['log', '--no-walk']
- + self.logopts
- + new_commits_list
- + ['--'],
- keepends=True,):
- yield line
- def generate_revision_change_summary(self, push):
- """Generate a summary of the revisions added/removed by this
- change."""
- if self.new.commit_sha1 and not self.old.commit_sha1:
- # A new reference was created. List the new revisions
- # brought by the new reference (i.e., those revisions that
- # were not in the repository before this reference
- # change).
- sha1s = list(push.get_new_commits(self))
- sha1s.reverse()
- tot = len(sha1s)
- new_revisions = [
- Revision(self, GitObject(sha1), num=i+1, tot=tot)
- for (i, sha1) in enumerate(sha1s)
- ]
- if new_revisions:
- yield self.expand(
- 'This %(refname_type)s includes the following new '
- 'commits:\n')
- yield '\n'
- for r in new_revisions:
- (sha1, subject) = r.rev.get_summary()
- yield r.expand(
- BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
- )
- yield '\n'
- for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
- yield line
- for line in self.generate_revision_change_log(
- [r.rev.sha1 for r in new_revisions]):
- yield line
- else:
- for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
- yield line
- elif self.new.commit_sha1 and self.old.commit_sha1:
- # A reference was changed to point at a different commit.
- # List the revisions that were removed and/or added *from
- # that reference* by this reference change, along with a
- # diff between the trees for its old and new values.
- # List of the revisions that were added to the branch by
- # this update. Note this list can include revisions that
- # have already had notification emails; we want such
- # revisions in the summary even though we will not send
- # new notification emails for them.
- adds = list(generate_summaries(
- '--topo-order', '--reverse', '%s..%s'
- % (self.old.commit_sha1, self.new.commit_sha1,)
- ))
- # List of the revisions that were removed from the branch
- # by this update. This will be empty except for
- # non-fast-forward updates.
- discards = list(generate_summaries(
- '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
- ))
- if adds:
- new_commits_list = push.get_new_commits(self)
- else:
- new_commits_list = []
- new_commits = CommitSet(new_commits_list)
- if discards:
- discarded_commits = CommitSet(
- push.get_discarded_commits(self))
- else:
- discarded_commits = CommitSet([])
- if discards and adds:
- for (sha1, subject) in discards:
- if sha1 in discarded_commits:
- action = 'discards'
- else:
- action = 'omits'
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action=action,
- rev_short=sha1, text=subject,
- )
- for (sha1, subject) in adds:
- if sha1 in new_commits:
- action = 'new'
- else:
- action = 'adds'
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action=action,
- rev_short=sha1, text=subject,
- )
- yield '\n'
- for line in self.expand_lines(NON_FF_TEMPLATE):
- yield line
- elif discards:
- for (sha1, subject) in discards:
- if sha1 in discarded_commits:
- action = 'discards'
- else:
- action = 'omits'
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action=action,
- rev_short=sha1, text=subject,
- )
- yield '\n'
- for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
- yield line
- elif adds:
- (sha1, subject) = self.old.get_summary()
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action='from',
- rev_short=sha1, text=subject,
- )
- for (sha1, subject) in adds:
- if sha1 in new_commits:
- action = 'new'
- else:
- action = 'adds'
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action=action,
- rev_short=sha1, text=subject,
- )
- yield '\n'
- if new_commits:
- for line in self.expand_lines(
- NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
- yield line
- for line in self.generate_revision_change_log(
- new_commits_list):
- yield line
- else:
- for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
- yield line
- # The diffstat is shown from the old revision to the new
- # revision. This is to show the truth of what happened in
- # this change. There's no point showing the stat from the
- # base to the new revision because the base is effectively a
- # random revision at this point - the user will be interested
- # in what this revision changed - including the undoing of
- # previous revisions in the case of non-fast-forward updates.
- yield '\n'
- yield 'Summary of changes:\n'
- for line in read_git_lines(
- ['diff-tree']
- + self.diffopts
- + ['%s..%s' % (
- self.old.commit_sha1, self.new.commit_sha1,)],
- keepends=True,):
- yield line
- elif self.old.commit_sha1 and not self.new.commit_sha1:
- # A reference was deleted. List the revisions that were
- # removed from the repository by this reference change.
- sha1s = list(push.get_discarded_commits(self))
- tot = len(sha1s)
- discarded_revisions = [
- Revision(self, GitObject(sha1), num=i+1, tot=tot)
- for (i, sha1) in enumerate(sha1s)
- ]
- if discarded_revisions:
- for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
- yield line
- yield '\n'
- for r in discarded_revisions:
- (sha1, subject) = r.rev.get_summary()
- yield r.expand(
- BRIEF_SUMMARY_TEMPLATE, action='discards',
- text=subject,
- )
- else:
- for line in self.expand_lines(
- NO_DISCARDED_REVISIONS_TEMPLATE):
- yield line
- elif not self.old.commit_sha1 and not self.new.commit_sha1:
- for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
- yield line
- def generate_create_summary(self, push):
- """Called for the creation of a reference."""
- # This is a new reference and so oldrev is not valid
- (sha1, subject) = self.new.get_summary()
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action='at',
- rev_short=sha1, text=subject,
- )
- yield '\n'
- def generate_update_summary(self, push):
- """Called for the change of a pre-existing branch."""
- return iter([])
- def generate_delete_summary(self, push):
- """Called for the deletion of any type of reference."""
- (sha1, subject) = self.old.get_summary()
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action='was',
- rev_short=sha1, text=subject,
- )
- yield '\n'
- class BranchChange(ReferenceChange):
- refname_type = 'branch'
- def __init__(self, environment, refname, short_refname, old, new, rev):
- ReferenceChange.__init__(
- self, environment,
- refname=refname, short_refname=short_refname,
- old=old, new=new, rev=rev,
- )
- self.recipients = environment.get_refchange_recipients(self)
- class AnnotatedTagChange(ReferenceChange):
- refname_type = 'annotated tag'
- def __init__(self, environment, refname, short_refname, old, new, rev):
- ReferenceChange.__init__(
- self, environment,
- refname=refname, short_refname=short_refname,
- old=old, new=new, rev=rev,
- )
- self.recipients = environment.get_announce_recipients(self)
- self.show_shortlog = environment.announce_show_shortlog
- ANNOTATED_TAG_FORMAT = (
- '%(*objectname)\n'
- '%(*objecttype)\n'
- '%(taggername)\n'
- '%(taggerdate)'
- )
- def describe_tag(self, push):
- """Describe the new value of an annotated tag."""
- # Use git for-each-ref to pull out the individual fields from
- # the tag
- [tagobject, tagtype, tagger, tagged] = read_git_lines(
- ['for-each-ref', '--format=%s' % (
- self.ANNOTATED_TAG_FORMAT,), self.refname],
- )
- yield self.expand(
- BRIEF_SUMMARY_TEMPLATE, action='tagging',
- rev_short=tagobject, text='(%s)' % (tagtype,),
- )
- if tagtype == 'commit':
- # If the tagged object is a commit, then we assume this is a
- # release, and so we calculate which tag this tag is
- # replacing
- try:
- prevtag = read_git_output(
- ['describe', '--abbrev=0', '%s^' % (self.new,)])
- except CommandError:
- prevtag = None
- if prevtag:
- yield ' replaces %s\n' % (prevtag,)
- else:
- prevtag = None
- yield ' length %s bytes\n' % (read_git_output(
- ['cat-file', '-s', tagobject]),)
- yield ' tagged by %s\n' % (tagger,)
- yield ' on %s\n' % (tagged,)
- yield '\n'
- # Show the content of the tag message; this might contain a
- # change log or release notes so is worth displaying.
- yield LOGBEGIN
- contents = list(read_git_lines(
- ['cat-file', 'tag', self.new.sha1], keepends=True))
- contents = contents[contents.index('\n') + 1:]
- if contents and contents[-1][-1:] != '\n':
- contents.append('\n')
- for line in contents:
- yield line
- if self.show_shortlog and tagtype == 'commit':
- # Only commit tags make sense to have rev-list operations
- # performed on them
- yield '\n'
- if prevtag:
- # Show changes since the previous release
- revlist = read_git_output(
- ['rev-list', '--pretty=short',
- '%s..%s' % (prevtag, self.new,)],
- keepends=True,
- )
- else:
- # No previous tag, show all the changes since time
- # began
- revlist = read_git_output(
- ['rev-list', '--pretty=short', '%s' % (self.new,)],
- keepends=True,
- )
- for line in read_git_lines(
- ['shortlog'], input=revlist, keepends=True):
- yield line
- yield LOGEND
- yield '\n'
- def generate_create_summary(self, push):
- """Called for the creation of an annotated tag."""
- for line in self.expand_lines(TAG_CREATED_TEMPLATE):
- yield line
- for line in self.describe_tag(push):
- yield line
- def generate_update_summary(self, push):
- """Called for the update of an annotated tag.
- This is probably a rare event and may not even be allowed."""
- for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
- yield line
- for line in self.describe_tag(push):
- yield line
- def generate_delete_summary(self, push):
- """Called when a non-annotated reference is updated."""
- for line in self.expand_lines(TAG_DELETED_TEMPLATE):
- yield line
- yield self.expand(' tag was %(oldrev_short)s\n')
- yield '\n'
- class NonAnnotatedTagChange(ReferenceChange):
- refname_type = 'tag'
- def __init__(self, environment, refname, short_refname, old, new, rev):
- ReferenceChange.__init__(
- self, environment,
- refname=refname, short_refname=short_refname,
- old=old, new=new, rev=rev,
- )
- self.recipients = environment.get_refchange_recipients(self)
- def generate_create_summary(self, push):
- """Called for the creation of an annotated tag."""
- for line in self.expand_lines(TAG_CREATED_TEMPLATE):
- yield line
- def generate_update_summary(self, push):
- """Called when a non-annotated reference is updated."""
- for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
- yield line
- def generate_delete_summary(self, push):
- """Called when a non-annotated reference is updated."""
- for line in self.expand_lines(TAG_DELETED_TEMPLATE):
- yield line
- for line in ReferenceChange.generate_delete_summary(self, push):
- yield line
- class OtherReferenceChange(ReferenceChange):
- refname_type = 'reference'
- def __init__(self, environment, refname, short_refname, old, new, rev):
- # We use the full refname as short_refname, because otherwise
- # the full name of the reference would not be obvious from the
- # text of the email.
- ReferenceChange.__init__(
- self, environment,
- refname=refname, short_refname=refname,
- old=old, new=new, rev=rev,
- )
- self.recipients = environment.get_refchange_recipients(self)
- class Mailer(object):
- """An object that can send emails."""
- def send(self, lines, to_addrs):
- """Send an email consisting of lines.
- lines must be an iterable over the lines constituting the
- header and body of the email. to_addrs is a list of recipient
- addresses (can be needed even if lines already contains a
- "To:" field). It can be either a string (comma-separated list
- of email addresses) or a Python list of individual email
- addresses.
- """
- raise NotImplementedError()
- class SendMailer(Mailer):
- """Send emails using 'sendmail -oi -t'."""
- SENDMAIL_CANDIDATES = [
- '/usr/sbin/sendmail',
- '/usr/lib/sendmail',
- ]
- @staticmethod
- def find_sendmail():
- for path in SendMailer.SENDMAIL_CANDIDATES:
- if os.access(path, os.X_OK):
- return path
- else:
- raise ConfigurationException(
- 'No sendmail executable found. '
- 'Try setting multimailhook.sendmailCommand.'
- )
- def __init__(self, command=None, envelopesender=None):
- """Construct a SendMailer instance.
- command should be the command and arguments used to invoke
- sendmail, as a list of strings. If an envelopesender is
- provided, it will also be passed to the command, via '-f
- envelopesender'."""
- if command:
- self.command = command[:]
- else:
- self.command = [self.find_sendmail(), '-oi', '-t']
- if envelopesender:
- self.command.extend(['-f', envelopesender])
- def send(self, lines, to_addrs):
- try:
- p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
- except OSError as e:
- sys.stderr.write(
- '*** Cannot execute command: %s\n' % ' '.join(self.command)
- + '*** %s\n' % e
- + '*** Try setting multimailhook.mailer to "smtp"\n'
- '*** to send emails without using the sendmail command.\n'
- )
- sys.exit(1)
- try:
- p.stdin.writelines(lines)
- except:
- sys.stderr.write(
- '*** Error while generating commit email\n'
- '*** - mail sending aborted.\n'
- )
- p.terminate()
- raise
- else:
- p.stdin.close()
- retcode = p.wait()
- if retcode:
- raise CommandError(self.command, retcode)
- class SMTPMailer(Mailer):
- """Send emails using Python's smtplib."""
- def __init__(self, envelopesender, smtpserver):
- if not envelopesender:
- sys.stderr.write(
- 'fatal: git_multimail: cannot use SMTPMailer without a '
- 'sender address.\n'
- 'please set either multimailhook.envelopeSender or '
- 'user.email\n'
- )
- sys.exit(1)
- self.envelopesender = envelopesender
- self.smtpserver = smtpserver
- try:
- self.smtp = smtplib.SMTP(self.smtpserver)
- except Exception as e:
- sys.stderr.write(
- '*** Error establishing SMTP connection to %s***\n' %
- self.smtpserver)
- sys.stderr.write('*** %s\n' % e)
- sys.exit(1)
- def __del__(self):
- self.smtp.quit()
- def send(self, lines, to_addrs):
- try:
- msg = ''.join(lines)
- # turn comma-separated list into Python list if needed.
- if isinstance(to_addrs, six.string_types):
- to_addrs = [
- email for (name, email) in getaddresses([to_addrs])]
- self.smtp.sendmail(self.envelopesender, to_addrs, msg)
- except Exception as e:
- sys.stderr.write('*** Error sending email***\n')
- sys.stderr.write('*** %s\n' % e)
- self.smtp.quit()
- sys.exit(1)
- class OutputMailer(Mailer):
- """Write emails to an output stream, bracketed by lines of '='
- characters.
- This is intended for debugging purposes."""
- SEPARATOR = '=' * 75 + '\n'
- def __init__(self, f):
- self.f = f
- def send(self, lines, to_addrs):
- self.f.write(self.SEPARATOR)
- self.f.writelines(lines)
- self.f.write(self.SEPARATOR)
- def get_git_dir():
- """Determine GIT_DIR.
- Determine GIT_DIR either from the GIT_DIR environment variable or
- from the working directory, using Git's usual rules."""
- try:
- return read_git_output(['rev-parse', '--git-dir'])
- except CommandError:
- sys.stderr.write('fatal: git_multimail: not in a git directory\n')
- sys.exit(1)
- class Environment(object):
- """Describes the environment in which the push is occurring.
- An Environment object encapsulates information about the local
- environment. For example, it knows how to determine:
- * the name of the repository to which the push occurred
- * what user did the push
- * what users want to be informed about various types of changes.
- An Environment object is expected to have the following methods:
- get_repo_shortname()
- Return a short name for the repository, for display
- purposes.
- get_repo_path()
- Return the absolute path to the Git repository.
- get_emailprefix()
- Return a string that will be prefixed to every email's
- subject.
- get_pusher()
- Return the username of the person who pushed the changes.
- This value is used in the email body to indicate who
- pushed the change.
- get_pusher_email() (may return None)
- Return the email address of the person who pushed the
- changes. The value should be a single RFC 2822 email
- address as a string; e.g., "Joe User <user@example.com>"
- if available, otherwise "user@example.com". If set, the
- value is used as the Reply-To address for refchange
- emails. If it is impossible to determine the pusher's
- email, this attribute should be set to None (in which case
- no Reply-To header will be output).
- get_sender()
- Return the address to be used as the 'From' email address
- in the email envelope.
- get_fromaddr()
- Return the 'From' email address used in the email 'From:'
- headers. (May be a full RFC 2822 email address like 'Joe
- User <user@example.com>'.)
- get_administrator()
- Return the name and/or email of the repository
- administrator. This value is used in the footer as the
- person to whom requests to be removed from the
- notification list should be sent. Ideally, it should
- include a valid email address.
- get_reply_to_refchange()
- get_reply_to_commit()
- Return the address to use in the email "Reply-To" header,
- as a string. These can be an RFC 2822 email address, or
- None to omit the "Reply-To" header.
- get_reply_to_refchange() is used for refchange emails;
- get_reply_to_commit() is used for individual commit
- emails.
- They should also define the following attributes:
- announce_show_shortlog (bool)
- True iff announce emails should include a shortlog.
- refchange_showlog (bool)
- True iff refchanges emails should include a detailed log.
- diffopts (list of strings)
- The options that should be passed to 'git diff' for the
- summary email. The value should be a list of strings
- representing words to be passed to the command.
- logopts (list of strings)
- Analogous to diffopts, but contains options passed to
- 'git log' when generating the detailed log for a set of
- commits (see refchange_showlog)
- commitlogopts (list of strings)
- The options that should be passed to 'git log' for each
- commit mail. The value should be a list of strings
- representing words to be passed to the command.
- """
- REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
- def __init__(self, osenv=None):
- self.osenv = osenv or os.environ
- self.announce_show_shortlog = False
- self.maxcommitemails = 500
- self.diffopts = ['--stat', '--summary', '--find-copies-harder']
- self.logopts = []
- self.refchange_showlog = False
- self.commitlogopts = ['-C', '--stat', '-p', '--cc']
- self.COMPUTED_KEYS = [
- 'administrator',
- 'charset',
- 'emailprefix',
- 'fromaddr',
- 'pusher',
- 'pusher_email',
- 'repo_path',
- 'repo_shortname',
- 'sender',
- ]
- self._values = None
- def get_repo_shortname(self):
- """Use the last part of the repo path, with ".git" stripped off if
- present."""
- basename = os.path.basename(os.path.abspath(self.get_repo_path()))
- m = self.REPO_NAME_RE.match(basename)
- if m:
- return m.group('name')
- else:
- return basename
- def get_pusher(self):
- raise NotImplementedError()
- def get_pusher_email(self):
- return None
- def get_administrator(self):
- return 'the administrator of this repository'
- def get_emailprefix(self):
- return ''
- def get_repo_path(self):
- if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
- path = get_git_dir()
- else:
- path = read_git_output(['rev-parse', '--show-toplevel'])
- return os.path.abspath(path)
- def get_charset(self):
- return CHARSET
- def get_values(self):
- """Return a dictionary {keyword : expansion} for this Environment.
- This method is called by Change._compute_values(). The keys
- in the returned dictionary are available to be used in any of
- the templates. The dictionary is created by calling
- self.get_NAME() for each of the attributes named in
- COMPUTED_KEYS and recording those that do not return None.
- The return value is always a new dictionary."""
- if self._values is None:
- values = {}
- for key in self.COMPUTED_KEYS:
- value = getattr(self, 'get_%s' % (key,))()
- if value is not None:
- values[key] = value
- self._values = values
- return self._values.copy()
- def get_refchange_recipients(self, refchange):
- """Return the recipients for notifications about refchange.
- Return the list of email addresses to which notifications
- about the specified ReferenceChange should be sent."""
- raise NotImplementedError()
- def get_announce_recipients(self, annotated_tag_change):
- """Return the recipients for notifications about
- annotated_tag_change.
- Return the list of email addresses to which notifications
- about the specified AnnotatedTagChange should be sent."""
- raise NotImplementedError()
- def get_reply_to_refchange(self, refchange):
- return self.get_pusher_email()
- def get_revision_recipients(self, revision):
- """Return the recipients for messages about revision.
- Return the list of email addresses to which notifications
- about the specified Revision should be sent. This method
- could be overridden, for example, to take into account the
- contents of the revision when deciding whom to notify about
- it. For example, there could be a scheme for users to express
- interest in particular files or subdirectories, and only
- receive notification emails for revisions that affecting those
- files."""
- raise NotImplementedError()
- def get_reply_to_commit(self, revision):
- return revision.author
- def filter_body(self, lines):
- """Filter the lines intended for an email body.
- lines is an iterable over the lines that would go into the
- email body. Filter it (e.g., limit the number of lines, the
- line length, character set, etc.), returning another iterable.
- See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
- for classes implementing this functionality."""
- return lines
- class ConfigEnvironmentMixin(Environment):
- """A mixin that sets self.config to its constructor's config argument.
- This class's constructor consumes the "config" argument.
- Mixins that need to inspect the config should inherit from this
- class (1) to make sure that "config" is still in the constructor
- arguments with its own constructor runs and/or (2) to be sure that
- self.config is set after construction."""
- def __init__(self, config, **kw):
- super(ConfigEnvironmentMixin, self).__init__(**kw)
- self.config = config
- class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
- """An Environment that reads most of its information from "git config".
- """
- def __init__(self, config, **kw):
- super(ConfigOptionsEnvironmentMixin, self).__init__(
- config=config, **kw
- )
- self.announce_show_shortlog = config.get_bool(
- 'announceshortlog', default=self.announce_show_shortlog
- )
- self.refchange_showlog = config.get_bool(
- 'refchangeshowlog', default=self.refchange_showlog
- )
- maxcommitemails = config.get('maxcommitemails')
- if maxcommitemails is not None:
- try:
- self.maxcommitemails = int(maxcommitemails)
- except ValueError:
- sys.stderr.write(
- '*** Malformed value for multimailhook.maxCommitEmails: '
- '%s\n' % maxcommitemails
- + '*** Expected a number. Ignoring.\n'
- )
- diffopts = config.get('diffopts')
- if diffopts is not None:
- self.diffopts = shlex.split(diffopts)
- logopts = config.get('logopts')
- if logopts is not None:
- self.logopts = shlex.split(logopts)
- commitlogopts = config.get('commitlogopts')
- if commitlogopts is not None:
- self.commitlogopts = shlex.split(commitlogopts)
- reply_to = config.get('replyTo')
- self.__reply_to_refchange = config.get(
- 'replyToRefchange', default=reply_to)
- if (
- self.__reply_to_refchange is not None
- and self.__reply_to_refchange.lower() == 'author'):
- raise ConfigurationException(
- '"author" is not an allowed setting for replyToRefchange'
- )
- self.__reply_to_commit = config.get(
- 'replyToCommit', default=reply_to)
- def get_administrator(self):
- return (
- self.config.get('administrator')
- or self.get_sender()
- or super(
- ConfigOptionsEnvironmentMixin, self).get_administrator()
- )
- def get_repo_shortname(self):
- return (
- self.config.get('reponame')
- or super(
- ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
- )
- def get_emailprefix(self):
- emailprefix = self.config.get('emailprefix')
- if emailprefix and emailprefix.strip():
- return emailprefix.strip() + ' '
- else:
- return '[%s] ' % (self.get_repo_shortname(),)
- def get_sender(self):
- return self.config.get('envelopesender')
- def get_fromaddr(self):
- fromaddr = self.config.get('from')
- if fromaddr:
- return fromaddr
- else:
- config = Config('user')
- fromname = config.get('name', default='Pagure')
- fromemail = config.get('email', default='')
- if fromemail:
- return formataddr([fromname, fromemail])
- else:
- return self.get_sender()
- def get_reply_to_refchange(self, refchange):
- if self.__reply_to_refchange is None:
- return super(
- ConfigOptionsEnvironmentMixin, self
- ).get_reply_to_refchange(refchange)
- elif self.__reply_to_refchange.lower() == 'pusher':
- return self.get_pusher_email()
- elif self.__reply_to_refchange.lower() == 'none':
- return None
- else:
- return self.__reply_to_refchange
- def get_reply_to_commit(self, revision):
- if self.__reply_to_commit is None:
- return super(
- ConfigOptionsEnvironmentMixin, self
- ).get_reply_to_commit(revision)
- elif self.__reply_to_commit.lower() == 'author':
- return revision.get_author()
- elif self.__reply_to_commit.lower() == 'pusher':
- return self.get_pusher_email()
- elif self.__reply_to_commit.lower() == 'none':
- return None
- else:
- return self.__reply_to_commit
- class FilterLinesEnvironmentMixin(Environment):
- """Handle encoding and maximum line length of body lines.
- emailmaxlinelength (int or None)
- The maximum length of any single line in the email body.
- Longer lines are truncated at that length with ' [...]'
- appended.
- strict_utf8 (bool)
- If this field is set to True, then the email body text is
- expected to be UTF-8. Any invalid characters are
- converted to U+FFFD, the Unicode replacement character
- (encoded as UTF-8, of course).
- """
- def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
- super(FilterLinesEnvironmentMixin, self).__init__(**kw)
- self.__strict_utf8 = strict_utf8
- self.__emailmaxlinelength = emailmaxlinelength
- def filter_body(self, lines):
- lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
- if self.__strict_utf8:
- lines = (line.decode(ENCODING, 'replace') for line in lines)
- # Limit the line length in Unicode-space to avoid
- # splitting characters:
- if self.__emailmaxlinelength:
- lines = limit_linelength(lines, self.__emailmaxlinelength)
- lines = (line.encode(ENCODING, 'replace') for line in lines)
- elif self.__emailmaxlinelength:
- lines = limit_linelength(lines, self.__emailmaxlinelength)
- return lines
- class ConfigFilterLinesEnvironmentMixin(
- ConfigEnvironmentMixin,
- FilterLinesEnvironmentMixin,):
- """Handle encoding and maximum line length based on config."""
- def __init__(self, config, **kw):
- strict_utf8 = config.get_bool('emailstrictutf8', default=None)
- if strict_utf8 is not None:
- kw['strict_utf8'] = strict_utf8
- emailmaxlinelength = config.get('emailmaxlinelength')
- if emailmaxlinelength is not None:
- kw['emailmaxlinelength'] = int(emailmaxlinelength)
- super(ConfigFilterLinesEnvironmentMixin, self).__init__(
- config=config, **kw
- )
- class MaxlinesEnvironmentMixin(Environment):
- """Limit the email body to a specified number of lines."""
- def __init__(self, emailmaxlines, **kw):
- super(MaxlinesEnvironmentMixin, self).__init__(**kw)
- self.__emailmaxlines = emailmaxlines
- def filter_body(self, lines):
- lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
- if self.__emailmaxlines:
- lines = limit_lines(lines, self.__emailmaxlines)
- return lines
- class ConfigMaxlinesEnvironmentMixin(
- ConfigEnvironmentMixin,
- MaxlinesEnvironmentMixin,):
- """Limit the email body to the number of lines specified in config."""
- def __init__(self, config, **kw):
- emailmaxlines = int(config.get('emailmaxlines', default='0'))
- super(ConfigMaxlinesEnvironmentMixin, self).__init__(
- config=config,
- emailmaxlines=emailmaxlines,
- **kw
- )
- class FQDNEnvironmentMixin(Environment):
- """A mixin that sets the host's FQDN to its constructor argument."""
- def __init__(self, fqdn, **kw):
- super(FQDNEnvironmentMixin, self).__init__(**kw)
- self.COMPUTED_KEYS += ['fqdn']
- self.__fqdn = fqdn
- def get_fqdn(self):
- """Return the fully-qualified domain name for this host.
- Return None if it is unavailable or unwanted."""
- return self.__fqdn
- class ConfigFQDNEnvironmentMixin(
- ConfigEnvironmentMixin,
- FQDNEnvironmentMixin,):
- """Read the FQDN from the config."""
- def __init__(self, config, **kw):
- fqdn = config.get('fqdn')
- super(ConfigFQDNEnvironmentMixin, self).__init__(
- config=config,
- fqdn=fqdn,
- **kw
- )
- class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
- """Get the FQDN by calling socket.getfqdn()."""
- def __init__(self, **kw):
- super(ComputeFQDNEnvironmentMixin, self).__init__(
- fqdn=socket.getfqdn(),
- **kw
- )
- class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
- """Deduce pusher_email from pusher by appending an emaildomain."""
- def __init__(self, **kw):
- super(PusherDomainEnvironmentMixin, self).__init__(**kw)
- self.__emaildomain = self.config.get('emaildomain')
- def get_pusher_email(self):
- if self.__emaildomain:
- # Derive the pusher's full email address in the default way:
- return '%s@%s' % (self.get_pusher(), self.__emaildomain)
- else:
- return super(
- PusherDomainEnvironmentMixin, self).get_pusher_email()
- class StaticRecipientsEnvironmentMixin(Environment):
- """Set recipients statically based on constructor parameters."""
- def __init__(
- self, refchange_recipients, announce_recipients,
- revision_recipients, **kw):
- super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
- # The recipients for various types of notification emails, as
- # RFC 2822 email addresses separated by commas (or the empty
- # string if no recipients are configured). Although there is
- # a mechanism to choose the recipient lists based on on the
- # actual *contents* of the change being reported, we only
- # choose based on the *type* of the change. Therefore we can
- # compute them once and for all:
- if not (refchange_recipients
- or announce_recipients
- or revision_recipients):
- raise ConfigurationException('No email recipients configured!')
- self.__refchange_recipients = refchange_recipients
- self.__announce_recipients = announce_recipients
- self.__revision_recipients = revision_recipients
- def get_refchange_recipients(self, refchange):
- return self.__refchange_recipients
- def get_announce_recipients(self, annotated_tag_change):
- return self.__announce_recipients
- def get_revision_recipients(self, revision):
- return self.__revision_recipients
- class ConfigRecipientsEnvironmentMixin(
- ConfigEnvironmentMixin,
- StaticRecipientsEnvironmentMixin):
- """Determine recipients statically based on config."""
- def __init__(self, config, **kw):
- super(ConfigRecipientsEnvironmentMixin, self).__init__(
- config=config,
- refchange_recipients=self._get_recipients(
- config, 'refchangelist', 'mailinglist',
- ),
- announce_recipients=self._get_recipients(
- config, 'announcelist', 'refchangelist', 'mailinglist',
- ),
- revision_recipients=self._get_recipients(
- config, 'commitlist', 'mailinglist',
- ),
- **kw
- )
- def _get_recipients(self, config, *names):
- """Return the recipients for a particular type of message.
- Return the list of email addresses to which a particular type
- of notification email should be sent, by looking at the config
- value for "multimailhook.$name" for each of names. Use the
- value from the first name that is configured. The return
- value is a (possibly empty) string containing RFC 2822 email
- addresses separated by commas. If no configuration could be
- found, raise a ConfigurationException."""
- for name in names:
- retval = config.get_recipients(name)
- if retval is not None:
- return retval
- else:
- return ''
- class ProjectdescEnvironmentMixin(Environment):
- """Make a "projectdesc" value available for templates.
- By default, it is set to the first line of $GIT_DIR/description
- (if that file is present and appears to be set meaningfully)."""
- def __init__(self, **kw):
- super(ProjectdescEnvironmentMixin, self).__init__(**kw)
- self.COMPUTED_KEYS += ['projectdesc']
- def get_projectdesc(self):
- """Return a one-line descripition of the project."""
- git_dir = get_git_dir()
- try:
- with open(os.path.join(git_dir, 'description')) as f:
- projectdesc = f.readline().strip()
- if projectdesc and not projectdesc.startswith(
- 'Unnamed repository'):
- return projectdesc
- except IOError:
- pass
- return 'UNNAMED PROJECT'
- class GenericEnvironmentMixin(Environment):
- def get_pusher(self):
- return self.osenv.get('USER', 'unknown user')
- class GenericEnvironment(
- ProjectdescEnvironmentMixin,
- ConfigMaxlinesEnvironmentMixin,
- ComputeFQDNEnvironmentMixin,
- ConfigFilterLinesEnvironmentMixin,
- ConfigRecipientsEnvironmentMixin,
- PusherDomainEnvironmentMixin,
- ConfigOptionsEnvironmentMixin,
- GenericEnvironmentMixin,
- Environment,
- ):
- pass
- class GitoliteEnvironmentMixin(Environment):
- def get_repo_shortname(self):
- # The gitolite environment variable $GL_REPO is a pretty good
- # repo_shortname (though it's probably not as good as a value
- # the user might have explicitly put in his config).
- return (
- self.osenv.get('GL_REPO', None)
- or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
- )
- def get_pusher(self):
- return self.osenv.get('GL_USER', 'unknown user')
- class IncrementalDateTime(six.Iterator):
- """Simple wrapper to give incremental date/times.
- Each call will result in a date/time a second later than the
- previous call. This can be used to falsify email headers, to
- increase the likelihood that email clients sort the emails
- correctly."""
- def __init__(self):
- self.time = time.time()
- def __next__(self):
- formatted = formatdate(self.time, True)
- self.time += 1
- return formatted
- class GitoliteEnvironment(
- ProjectdescEnvironmentMixin,
- ConfigMaxlinesEnvironmentMixin,
- ComputeFQDNEnvironmentMixin,
- ConfigFilterLinesEnvironmentMixin,
- ConfigRecipientsEnvironmentMixin,
- PusherDomainEnvironmentMixin,
- ConfigOptionsEnvironmentMixin,
- GitoliteEnvironmentMixin,
- Environment,
- ):
- pass
- class Push(object):
- """Represent an entire push (i.e., a group of ReferenceChanges).
- It is easy to figure out what commits were added to a *branch* by
- a Reference change:
- git rev-list change.old..change.new
- or removed from a *branch*:
- git rev-list change.new..change.old
- But it is not quite so trivial to determine which entirely new
- commits were added to the *repository* by a push and which old
- commits were discarded by a push. A big part of the job of this
- class is to figure out these things, and to make sure that new
- commits are only detailed once even if they were added to multiple
- references.
- The first step is to determine the "other" references--those
- unaffected by the current push. They are computed by
- Push._compute_other_ref_sha1s() by listing all references then
- removing any affected by this push.
- The commits contained in the repository before this push were
- git rev-list other1 other2 other3 ... change1.old change2.old ...
- Where "changeN.old" is the old value of one of the references
- affected by this push.
- The commits contained in the repository after this push are
- git rev-list other1 other2 other3 ... change1.new change2.new ...
- The commits added by this push are the difference between these
- two sets, which can be written
- git rev-list \
- ^other1 ^other2 ... \
- ^change1.old ^change2.old ... \
- change1.new change2.new ...
- The commits removed by this push can be computed by
- git rev-list \
- ^other1 ^other2 ... \
- ^change1.new ^change2.new ... \
- change1.old change2.old ...
- The last point is that it is possible that other pushes are
- occurring simultaneously to this one, so reference values can
- change at any time. It is impossible to eliminate all race
- conditions, but we reduce the window of time during which problems
- can occur by translating reference names to SHA1s as soon as
- possible and working with SHA1s thereafter (because SHA1s are
- immutable)."""
- # A map {(changeclass, changetype) : integer} specifying the order
- # that reference changes will be processed if multiple reference
- # changes are included in a single push. The order is significant
- # mostly because new commit notifications are threaded together
- # with the first reference change that includes the commit. The
- # following order thus causes commits to be grouped with branch
- # changes (as opposed to tag changes) if possible.
- SORT_ORDER = dict(
- (value, i) for (i, value) in enumerate([
- (BranchChange, 'update'),
- (BranchChange, 'create'),
- (AnnotatedTagChange, 'update'),
- (AnnotatedTagChange, 'create'),
- (NonAnnotatedTagChange, 'update'),
- (NonAnnotatedTagChange, 'create'),
- (BranchChange, 'delete'),
- (AnnotatedTagChange, 'delete'),
- (NonAnnotatedTagChange, 'delete'),
- (OtherReferenceChange, 'update'),
- (OtherReferenceChange, 'create'),
- (OtherReferenceChange, 'delete'),
- ])
- )
- def __init__(self, changes):
- self.changes = sorted(changes, key=self._sort_key)
- # The SHA-1s of commits referred to by references unaffected
- # by this push:
- other_ref_sha1s = self._compute_other_ref_sha1s()
- self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
- other_ref_sha1s.union(
- change.old.sha1
- for change in self.changes
- if change.old.type in ['commit', 'tag']
- )
- )
- self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
- other_ref_sha1s.union(
- change.new.sha1
- for change in self.changes
- if change.new.type in ['commit', 'tag']
- )
- )
- @classmethod
- def _sort_key(klass, change):
- return (
- klass.SORT_ORDER[change.__class__, change.change_type],
- change.refname,)
- def _compute_other_ref_sha1s(self):
- """Return the GitObjects referred to by references unaffected by
- this push."""
- # The refnames being changed by this push:
- updated_refs = set(
- change.refname
- for change in self.changes
- )
- # The SHA-1s of commits referred to by all references in this
- # repository *except* updated_refs:
- sha1s = set()
- fmt = (
- '%(objectname) %(objecttype) %(refname)\n'
- '%(*objectname) %(*objecttype) %(refname)'
- )
- for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
- (sha1, type, name) = line.split(' ', 2)
- if sha1 and type == 'commit' and name not in updated_refs:
- sha1s.add(sha1)
- return sha1s
- def _compute_rev_exclusion_spec(self, sha1s):
- """Return an exclusion specification for 'git rev-list'.
- git_objects is an iterable over GitObject instances. Return a
- string that can be passed to the standard input of 'git
- rev-list --stdin' to exclude all of the commits referred to by
- git_objects."""
- return ''.join(
- ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
- )
- def get_new_commits(self, reference_change=None):
- """Return a list of commits added by this push.
- Return a list of the object names of commits that were added
- by the part of this push represented by reference_change. If
- reference_change is None, then return a list of *all* commits
- added by this push."""
- if not reference_change:
- new_revs = sorted(
- change.new.sha1
- for change in self.changes
- if change.new
- )
- elif not reference_change.new.commit_sha1:
- return []
- else:
- new_revs = [reference_change.new.commit_sha1]
- cmd = ['rev-list', '--stdin'] + new_revs
- return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
- def get_discarded_commits(self, reference_change):
- """Return a list of commits discarded by this push.
- Return a list of the object names of commits that were
- entirely discarded from the repository by the part of this
- push represented by reference_change."""
- if not reference_change.old.commit_sha1:
- return []
- else:
- old_revs = [reference_change.old.commit_sha1]
- cmd = ['rev-list', '--stdin'] + old_revs
- return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
- def send_emails(self, mailer, body_filter=None):
- """Use send all of the notification emails needed for this push.
- Use send all of the notification emails (including reference
- change emails and commit emails) needed for this push. Send
- the emails using mailer. If body_filter is not None, then use
- it to filter the lines that are intended for the email
- body."""
- # The sha1s of commits that were introduced by this push.
- # They will be removed from this set as they are processed, to
- # guarantee that one (and only one) email is generated for
- # each new commit.
- unhandled_sha1s = set(self.get_new_commits())
- send_date = IncrementalDateTime()
- for change in self.changes:
- # Check if we've got anyone to send to
- if not change.recipients:
- sys.stderr.write(
- '*** no recipients configured so no email will be sent\n'
- '*** for %r update %s->%s\n'
- % (change.refname, change.old.sha1, change.new.sha1,)
- )
- else:
- sys.stderr.write(
- 'Sending notification emails to: %s\n' % (
- change.recipients,))
- extra_values = {'send_date': next(send_date)}
- mailer.send(
- change.generate_email(self, body_filter, extra_values),
- change.recipients,
- )
- sha1s = []
- for sha1 in reversed(list(self.get_new_commits(change))):
- if sha1 in unhandled_sha1s:
- sha1s.append(sha1)
- unhandled_sha1s.remove(sha1)
- max_emails = change.environment.maxcommitemails
- if max_emails and len(sha1s) > max_emails:
- sys.stderr.write(
- '*** Too many new commits (%d), not sending commit '
- 'emails.\n' % len(sha1s)
- + '*** Try setting multimailhook.maxCommitEmails to a '
- 'greater value\n'
- '*** Currently, multimailhook.maxCommitEmails=%d\n' %
- max_emails
- )
- return
- for (num, sha1) in enumerate(sha1s):
- rev = Revision(
- change, GitObject(sha1), num=num+1, tot=len(sha1s))
- if rev.recipients:
- extra_values = {'send_date': next(send_date)}
- mailer.send(
- rev.generate_email(self, body_filter, extra_values),
- rev.recipients,
- )
- # Consistency check:
- if unhandled_sha1s:
- sys.stderr.write(
- 'ERROR: No emails were sent for the following new commits:\n'
- ' %s\n'
- % ('\n '.join(sorted(unhandled_sha1s)),)
- )
- def run_as_post_receive_hook(environment, mailer):
- changes = []
- for line in sys.stdin:
- (oldrev, newrev, refname) = line.strip().split(' ', 2)
- changes.append(
- ReferenceChange.create(environment, oldrev, newrev, refname)
- )
- push = Push(changes)
- push.send_emails(mailer, body_filter=environment.filter_body)
- def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
- changes = [
- ReferenceChange.create(
- environment,
- read_git_output(['rev-parse', '--verify', oldrev]),
- read_git_output(['rev-parse', '--verify', newrev]),
- refname,
- ),
- ]
- push = Push(changes)
- push.send_emails(mailer, body_filter=environment.filter_body)
- def choose_mailer(config, environment):
- mailer = config.get('mailer', default='sendmail')
- if mailer == 'smtp':
- smtpserver = config.get('smtpserver', default='localhost')
- mailer = SMTPMailer(
- envelopesender=(
- environment.get_sender() or environment.get_fromaddr()
- ),
- smtpserver=smtpserver,
- )
- elif mailer == 'sendmail':
- command = config.get('sendmailcommand')
- if command:
- command = shlex.split(command)
- mailer = SendMailer(
- command=command, envelopesender=environment.get_sender())
- else:
- sys.stderr.write(
- 'fatal: multimailhook.mailer is set to an incorrect value: '
- '"%s"\n' % mailer
- + 'please use one of "smtp" or "sendmail".\n'
- )
- sys.exit(1)
- return mailer
- KNOWN_ENVIRONMENTS = {
- 'generic': GenericEnvironmentMixin,
- 'gitolite': GitoliteEnvironmentMixin,
- }
- def choose_environment(config, osenv=None, env=None, recipients=None):
- if not osenv:
- osenv = os.environ
- environment_mixins = [
- ProjectdescEnvironmentMixin,
- ConfigMaxlinesEnvironmentMixin,
- ComputeFQDNEnvironmentMixin,
- ConfigFilterLinesEnvironmentMixin,
- PusherDomainEnvironmentMixin,
- ConfigOptionsEnvironmentMixin,
- ]
- environment_kw = {
- 'osenv': osenv,
- 'config': config,
- }
- if not env:
- env = config.get('environment')
- if not env:
- if 'GL_USER' in osenv and 'GL_REPO' in osenv:
- env = 'gitolite'
- else:
- env = 'generic'
- environment_mixins.append(KNOWN_ENVIRONMENTS[env])
- if recipients:
- environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
- environment_kw['refchange_recipients'] = recipients
- environment_kw['announce_recipients'] = recipients
- environment_kw['revision_recipients'] = recipients
- else:
- environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
- environment_klass = type(
- 'EffectiveEnvironment',
- tuple(environment_mixins) + (Environment,),
- {},
- )
- return environment_klass(**environment_kw)
- def main(args):
- parser = optparse.OptionParser(
- description=__doc__,
- usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
- )
- parser.add_option(
- '--environment', '--env', action='store', type='choice',
- choices=['generic', 'gitolite'], default=None,
- help=(
- 'Choose type of environment is in use. Default is taken from '
- 'multimailhook.environment if set; otherwise "generic".'
- ),
- )
- parser.add_option(
- '--stdout', action='store_true', default=False,
- help='Output emails to stdout rather than sending them.',
- )
- parser.add_option(
- '--recipients', action='store', default=None,
- help='Set list of email recipients for all types of emails.',
- )
- parser.add_option(
- '--show-env', action='store_true', default=False,
- help=(
- 'Write to stderr the values determined for the environment '
- '(intended for debugging purposes).'
- ),
- )
- (options, args) = parser.parse_args(args)
- config = Config('multimailhook')
- try:
- environment = choose_environment(
- config, osenv=os.environ,
- env=options.environment,
- recipients=options.recipients,
- )
- if options.show_env:
- sys.stderr.write('Environment values:\n')
- for (k, v) in sorted(environment.get_values().items()):
- sys.stderr.write(' %s : %r\n' % (k, v))
- sys.stderr.write('\n')
- if options.stdout:
- mailer = OutputMailer(sys.stdout)
- else:
- mailer = choose_mailer(config, environment)
- # Dual mode: if arguments were specified on the command line, run
- # like an update hook; otherwise, run as a post-receive hook.
- if args:
- if len(args) != 3:
- parser.error('Need zero or three non-option arguments')
- (refname, oldrev, newrev) = args
- run_as_update_hook(environment, mailer, refname, oldrev, newrev)
- else:
- run_as_post_receive_hook(environment, mailer)
- except ConfigurationException as e:
- print(e, file=sys.stderr)
- sys.exit(1)
- if __name__ == '__main__':
- main(sys.argv[1:])
|