330 lines
10 KiB
Plaintext
330 lines
10 KiB
Plaintext
|
#! /usr/bin/python
|
|||
|
|
|||
|
# NOTE: Until SourceForge installs a modern version of Python on the cvs
|
|||
|
# servers, this script MUST be compatible with Python 1.5.2.
|
|||
|
|
|||
|
"""Complicated notification for CVS checkins.
|
|||
|
|
|||
|
This script is used to provide email notifications of changes to the CVS
|
|||
|
repository. These email changes will include context diffs of the changes.
|
|||
|
Really big diffs will be trimmed.
|
|||
|
|
|||
|
This script is run from a CVS loginfo file (see $CVSROOT/CVSROOT/loginfo). To
|
|||
|
set this up, create a loginfo entry that looks something like this:
|
|||
|
|
|||
|
mymodule /path/to/this/script %%s some-email-addr@your.domain
|
|||
|
|
|||
|
In this example, whenever a checkin that matches `mymodule' is made, this
|
|||
|
script is invoked, which will generate the diff containing email, and send it
|
|||
|
to some-email-addr@your.domain.
|
|||
|
|
|||
|
Note: This module used to also do repository synchronizations via
|
|||
|
rsync-over-ssh, but since the repository has been moved to SourceForge,
|
|||
|
this is no longer necessary. The syncing functionality has been ripped
|
|||
|
out in the 3.0, which simplifies it considerably. Access the 2.x versions
|
|||
|
to refer to this functionality. Because of this, the script is misnamed.
|
|||
|
|
|||
|
It no longer makes sense to run this script from the command line. Doing so
|
|||
|
will only print out this usage information.
|
|||
|
|
|||
|
Usage:
|
|||
|
|
|||
|
%(PROGRAM)s [options] <%%S> email-addr [email-addr ...]
|
|||
|
|
|||
|
Where options are:
|
|||
|
|
|||
|
--cvsroot=<path>
|
|||
|
Use <path> as the environment variable CVSROOT. Otherwise this
|
|||
|
variable must exist in the environment.
|
|||
|
|
|||
|
--context=#
|
|||
|
-C #
|
|||
|
Include # lines of context around lines that differ (default: 2).
|
|||
|
|
|||
|
-c
|
|||
|
Produce a context diff (default).
|
|||
|
|
|||
|
-u
|
|||
|
Produce a unified diff (smaller).
|
|||
|
|
|||
|
--quiet / -q
|
|||
|
Don't print as much status to stdout.
|
|||
|
|
|||
|
--fromhost=hostname
|
|||
|
-f hostname
|
|||
|
The hostname that email messages appear to be coming from. The From:
|
|||
|
header will of the outgoing message will look like user@hostname. By
|
|||
|
default, hostname is the machine's fully qualified domain name.
|
|||
|
|
|||
|
--help / -h
|
|||
|
Print this text.
|
|||
|
|
|||
|
The rest of the command line arguments are:
|
|||
|
|
|||
|
<%%S>
|
|||
|
CVS %%s loginfo expansion. When invoked by CVS, this will be a single
|
|||
|
string containing the directory the checkin is being made in, relative
|
|||
|
to $CVSROOT, followed by the list of files that are changing. If the
|
|||
|
%%s in the loginfo file is %%{sVv}, context diffs for each of the
|
|||
|
modified files are included in any email messages that are generated.
|
|||
|
|
|||
|
email-addrs
|
|||
|
At least one email address.
|
|||
|
"""
|
|||
|
__version__ = '1.2'
|
|||
|
|
|||
|
import os
|
|||
|
import sys
|
|||
|
import re
|
|||
|
import time
|
|||
|
import string
|
|||
|
import getopt
|
|||
|
import smtplib
|
|||
|
import pwd
|
|||
|
import socket
|
|||
|
|
|||
|
try:
|
|||
|
from socket import getfqdn
|
|||
|
except ImportError:
|
|||
|
def getfqdn():
|
|||
|
# Python 1.5.2 :(
|
|||
|
hostname = socket.gethostname()
|
|||
|
byaddr = socket.gethostbyaddr(socket.gethostbyname(hostname))
|
|||
|
aliases = byaddr[1]
|
|||
|
aliases.insert(0, byaddr[0])
|
|||
|
aliases.insert(0, hostname)
|
|||
|
for fqdn in aliases:
|
|||
|
if '.' in fqdn:
|
|||
|
break
|
|||
|
else:
|
|||
|
fqdn = 'localhost.localdomain'
|
|||
|
return fqdn
|
|||
|
|
|||
|
|
|||
|
from cStringIO import StringIO
|
|||
|
|
|||
|
# Which SMTP server to do we connect to? Empty string means localhost.
|
|||
|
MAILHOST = ''
|
|||
|
MAILPORT = 25
|
|||
|
|
|||
|
# Diff trimming stuff
|
|||
|
DIFF_HEAD_LINES = 20
|
|||
|
DIFF_TAIL_LINES = 20
|
|||
|
DIFF_TRUNCATE_IF_LARGER = 1000
|
|||
|
|
|||
|
EMPTYSTRING = ''
|
|||
|
SPACE = ' '
|
|||
|
DOT = '.'
|
|||
|
COMMASPACE = ', '
|
|||
|
|
|||
|
PROGRAM = sys.argv[0]
|
|||
|
|
|||
|
BINARY_EXPLANATION_LINES = [
|
|||
|
"(This appears to be a binary file; contents omitted.)\n"
|
|||
|
]
|
|||
|
|
|||
|
REVCRE = re.compile("^(NONE|[0-9.]+)$")
|
|||
|
NOVERSION = "Couldn't generate diff; no version number found in filespec: %s"
|
|||
|
BACKSLASH = "Couldn't generate diff: backslash in filespec's filename: %s"
|
|||
|
|
|||
|
|
|||
|
|
|||
|
def usage(code, msg=''):
|
|||
|
print __doc__ % globals()
|
|||
|
if msg:
|
|||
|
print msg
|
|||
|
sys.exit(code)
|
|||
|
|
|||
|
|
|||
|
|
|||
|
def calculate_diff(filespec, contextlines):
|
|||
|
file, oldrev, newrev = string.split(filespec, ',')
|
|||
|
# Make sure we can find a CVS version number
|
|||
|
if not REVCRE.match(oldrev):
|
|||
|
return NOVERSION % filespec
|
|||
|
if not REVCRE.match(newrev):
|
|||
|
return NOVERSION % filespec
|
|||
|
|
|||
|
if string.find(file, '\\') <> -1:
|
|||
|
# I'm sorry, a file name that contains a backslash is just too much.
|
|||
|
# XXX if someone wants to figure out how to escape the backslashes in
|
|||
|
# a safe way to allow filenames containing backslashes, this is the
|
|||
|
# place to do it. --Zooko 2002-03-17
|
|||
|
return BACKSLASH % filespec
|
|||
|
|
|||
|
if string.find(file, "'") <> -1:
|
|||
|
# Those crazy users put single-quotes in their file names! Now we
|
|||
|
# have to escape everything that is meaningful inside double-quotes.
|
|||
|
filestr = string.replace(file, '`', '\`')
|
|||
|
filestr = string.replace(filestr, '"', '\"')
|
|||
|
filestr = string.replace(filestr, '$', '\$')
|
|||
|
# and quote it with double-quotes.
|
|||
|
filestr = '"' + filestr + '"'
|
|||
|
else:
|
|||
|
# quote it with single-quotes.
|
|||
|
filestr = "'" + file + "'"
|
|||
|
if oldrev == 'NONE':
|
|||
|
try:
|
|||
|
if os.path.exists(file):
|
|||
|
fp = open(file)
|
|||
|
else:
|
|||
|
update_cmd = "cvs -fn update -r %s -p %s" % (newrev, filestr)
|
|||
|
fp = os.popen(update_cmd)
|
|||
|
lines = fp.readlines()
|
|||
|
fp.close()
|
|||
|
# Is this a binary file? Let's look at the first few
|
|||
|
# lines to figure it out:
|
|||
|
for line in lines[:5]:
|
|||
|
for c in string.rstrip(line):
|
|||
|
if c in string.whitespace:
|
|||
|
continue
|
|||
|
if c < ' ' or c > chr(127):
|
|||
|
lines = BINARY_EXPLANATION_LINES[:]
|
|||
|
break
|
|||
|
lines.insert(0, '--- NEW FILE: %s ---\n' % file)
|
|||
|
except IOError, e:
|
|||
|
lines = ['***** Error reading new file: ',
|
|||
|
str(e), '\n***** file: ', file, ' cwd: ', os.getcwd()]
|
|||
|
elif newrev == 'NONE':
|
|||
|
lines = ['--- %s DELETED ---\n' % file]
|
|||
|
else:
|
|||
|
# This /has/ to happen in the background, otherwise we'll run into CVS
|
|||
|
# lock contention. What a crock.
|
|||
|
if contextlines > 0:
|
|||
|
difftype = "-C " + str(contextlines)
|
|||
|
else:
|
|||
|
difftype = "-u"
|
|||
|
diffcmd = "/usr/bin/cvs -f diff -kk %s --minimal -r %s -r %s %s" \
|
|||
|
% (difftype, oldrev, newrev, filestr)
|
|||
|
fp = os.popen(diffcmd)
|
|||
|
lines = fp.readlines()
|
|||
|
sts = fp.close()
|
|||
|
# ignore the error code, it always seems to be 1 :(
|
|||
|
## if sts:
|
|||
|
## return 'Error code %d occurred during diff\n' % (sts >> 8)
|
|||
|
if len(lines) > DIFF_TRUNCATE_IF_LARGER:
|
|||
|
removedlines = len(lines) - DIFF_HEAD_LINES - DIFF_TAIL_LINES
|
|||
|
del lines[DIFF_HEAD_LINES:-DIFF_TAIL_LINES]
|
|||
|
lines.insert(DIFF_HEAD_LINES,
|
|||
|
'[...%d lines suppressed...]\n' % removedlines)
|
|||
|
return string.join(lines, '')
|
|||
|
|
|||
|
|
|||
|
|
|||
|
def blast_mail(subject, people, filestodiff, contextlines, fromhost):
|
|||
|
# cannot wait for child process or that will cause parent to retain cvs
|
|||
|
# lock for too long. Urg!
|
|||
|
if not os.fork():
|
|||
|
# in the child
|
|||
|
# give up the lock you cvs thang!
|
|||
|
time.sleep(2)
|
|||
|
# Create the smtp connection to the localhost
|
|||
|
conn = smtplib.SMTP()
|
|||
|
conn.connect(MAILHOST, MAILPORT)
|
|||
|
user = pwd.getpwuid(os.getuid())[0]
|
|||
|
name = pwd.getpwuid(os.getuid())[4]
|
|||
|
domain = fromhost or getfqdn()
|
|||
|
address = '%s@%s' % (user, domain)
|
|||
|
s = StringIO()
|
|||
|
sys.stdout = s
|
|||
|
try:
|
|||
|
print '''\
|
|||
|
From: %(name)s <%(address)s>
|
|||
|
To: %(people)s
|
|||
|
Subject: %(subject)s
|
|||
|
X-Mailer: Python syncmail %(version)s <http://sf.net/projects/cvs-syncmail>
|
|||
|
''' % {'address' : address,
|
|||
|
'name' : name,
|
|||
|
'people' : string.join(people, COMMASPACE),
|
|||
|
'subject' : subject,
|
|||
|
'version' : __version__,
|
|||
|
}
|
|||
|
s.write(sys.stdin.read())
|
|||
|
# append the diffs if available
|
|||
|
print
|
|||
|
for file in filestodiff:
|
|||
|
print calculate_diff(file, contextlines)
|
|||
|
finally:
|
|||
|
sys.stdout = sys.__stdout__
|
|||
|
resp = conn.sendmail(address, people, s.getvalue())
|
|||
|
conn.close()
|
|||
|
os._exit(0)
|
|||
|
|
|||
|
|
|||
|
|
|||
|
# scan args for options
|
|||
|
def main():
|
|||
|
try:
|
|||
|
opts, args = getopt.getopt(
|
|||
|
sys.argv[1:], 'hC:cuqf:',
|
|||
|
['fromhost=', 'context=', 'cvsroot=', 'help', 'quiet'])
|
|||
|
except getopt.error, msg:
|
|||
|
usage(1, msg)
|
|||
|
|
|||
|
# parse the options
|
|||
|
contextlines = 2
|
|||
|
verbose = 1
|
|||
|
fromhost = None
|
|||
|
for opt, arg in opts:
|
|||
|
if opt in ('-h', '--help'):
|
|||
|
usage(0)
|
|||
|
elif opt == '--cvsroot':
|
|||
|
os.environ['CVSROOT'] = arg
|
|||
|
elif opt in ('-C', '--context'):
|
|||
|
contextlines = int(arg)
|
|||
|
elif opt == '-c':
|
|||
|
if contextlines <= 0:
|
|||
|
contextlines = 2
|
|||
|
elif opt == '-u':
|
|||
|
contextlines = 0
|
|||
|
elif opt in ('-q', '--quiet'):
|
|||
|
verbose = 0
|
|||
|
elif opt in ('-f', '--fromhost'):
|
|||
|
fromhost = arg
|
|||
|
|
|||
|
# What follows is the specification containing the files that were
|
|||
|
# modified. The argument actually must be split, with the first component
|
|||
|
# containing the directory the checkin is being made in, relative to
|
|||
|
# $CVSROOT, followed by the list of files that are changing.
|
|||
|
if not args:
|
|||
|
usage(1, 'No CVS module specified')
|
|||
|
subject = args[0]
|
|||
|
specs = string.split(args[0])
|
|||
|
del args[0]
|
|||
|
|
|||
|
# The remaining args should be the email addresses
|
|||
|
if not args:
|
|||
|
usage(1, 'No recipients specified')
|
|||
|
|
|||
|
# Now do the mail command
|
|||
|
people = args
|
|||
|
|
|||
|
if verbose:
|
|||
|
print 'Mailing %s...' % string.join(people, COMMASPACE)
|
|||
|
|
|||
|
if specs == ['-', 'Imported', 'sources']:
|
|||
|
return
|
|||
|
if specs[-3:] == ['-', 'New', 'directory']:
|
|||
|
del specs[-3:]
|
|||
|
elif len(specs) > 2:
|
|||
|
L = specs[:2]
|
|||
|
for s in specs[2:]:
|
|||
|
prev = L[-1]
|
|||
|
if string.count(prev, ',') < 2:
|
|||
|
L[-1] = "%s %s" % (prev, s)
|
|||
|
else:
|
|||
|
L.append(s)
|
|||
|
specs = L
|
|||
|
|
|||
|
if verbose:
|
|||
|
print 'Generating notification message...'
|
|||
|
blast_mail(subject, people, specs[1:], contextlines, fromhost)
|
|||
|
if verbose:
|
|||
|
print 'Generating notification message... done.'
|
|||
|
|
|||
|
|
|||
|
|
|||
|
if __name__ == '__main__':
|
|||
|
main()
|
|||
|
sys.exit(0)
|