7e6c8679db
git-svn-id: https://pmd.svn.sourceforge.net/svnroot/pmd/trunk@2542 51baf565-9d33-0410-a72c-fc3788e3496d
439 lines
14 KiB
Python
Executable File
439 lines
14 KiB
Python
Executable File
#! /usr/bin/python
|
||
|
||
# Copyright (c) 2002, 2003, Barry Warsaw, Fred Drake, and contributors
|
||
# All rights reserved.
|
||
# See the accompanying LICENSE file for details.
|
||
|
||
# NOTE: SourceForge currently runs Python 2.2.3, so we need to remain
|
||
# compatible with the Python 2.2 line.
|
||
|
||
"""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).
|
||
|
||
-m hostname
|
||
--mailhost hostname
|
||
The hostname of an available SMTP server. The default is
|
||
'localhost'.
|
||
|
||
-u
|
||
Produce a unified diff (smaller).
|
||
|
||
-S TEXT
|
||
--subject-prefix=TEXT
|
||
Prepend TEXT to the email subject line.
|
||
|
||
-R ADDR
|
||
--reply-to=ADDR
|
||
Add a "Reply-To: ADDR" header to the email message.
|
||
|
||
--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 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.3'
|
||
|
||
import os
|
||
import sys
|
||
import re
|
||
import time
|
||
import getopt
|
||
import smtplib
|
||
import pwd
|
||
import socket
|
||
|
||
from cStringIO import StringIO
|
||
|
||
# Which SMTP server to do we connect to?
|
||
MAILHOST = 'localhost'
|
||
MAILPORT = 25
|
||
|
||
# Diff trimming stuff
|
||
DIFF_HEAD_LINES = 20
|
||
DIFF_TAIL_LINES = 20
|
||
DIFF_TRUNCATE_IF_LARGER = 1000
|
||
|
||
COMMASPACE = ', '
|
||
|
||
PROGRAM = sys.argv[0]
|
||
|
||
BINARY_EXPLANATION_LINES = [
|
||
"(This appears to be a binary file; contents omitted.)\n"
|
||
]
|
||
|
||
NOVERSION = "Couldn't generate diff; no version number found for file: %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(entry, contextlines):
|
||
file = entry.name
|
||
oldrev = entry.revision
|
||
newrev = entry.new_revision
|
||
|
||
# Make sure we can find a CVS version number
|
||
if oldrev is None and newrev is None:
|
||
return NOVERSION % file
|
||
|
||
if file.find("'") <> -1:
|
||
# Those crazy users put single-quotes in their file names! Now we
|
||
# have to escape everything that is meaningful inside double-quotes.
|
||
filestr = filestr.replace('\\', '\\\\')
|
||
filestr = filestr.replace('`', '\`')
|
||
filestr = filestr.replace('"', '\"')
|
||
filestr = filestr.replace('$', '\$')
|
||
# and quote it with double-quotes.
|
||
filestr = '"' + filestr + '"'
|
||
else:
|
||
# quote it with single-quotes.
|
||
filestr = "'" + file + "'"
|
||
if oldrev is None:
|
||
# File is being added.
|
||
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 line.rstrip():
|
||
if c.isspace():
|
||
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 is None:
|
||
lines = ['--- %s DELETED ---\n' % file]
|
||
else:
|
||
# File has been changed.
|
||
# 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()
|
||
# ignore the error code, it always seems to be 1 :(
|
||
fp.close()
|
||
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 ''.join(lines)
|
||
|
||
|
||
|
||
rfc822_specials_re = re.compile(r'[\(\)\<\>\@\,\;\:\\\"\.\[\]]')
|
||
|
||
def quotename(name):
|
||
if name and rfc822_specials_re.search(name):
|
||
return '"%s"' % name.replace('"', '\\"')
|
||
else:
|
||
return name
|
||
|
||
|
||
|
||
def blast_mail(subject, people, entries, contextlines, fromhost, replyto):
|
||
# 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)
|
||
pwinfo = pwd.getpwuid(os.getuid())
|
||
user = pwinfo[0]
|
||
name = pwinfo[4].split(',')[0]
|
||
domain = fromhost or socket.getfqdn()
|
||
address = '%s@%s' % (user, domain)
|
||
s = StringIO()
|
||
sys.stdout = s
|
||
datestamp = time.strftime('%a, %d %b %Y %H:%M:%S +0000',
|
||
time.gmtime(time.time()))
|
||
try:
|
||
vars = {'address' : address,
|
||
'name' : quotename(name),
|
||
'people' : COMMASPACE.join(people),
|
||
'subject' : subject,
|
||
'version' : __version__,
|
||
'date' : datestamp,
|
||
}
|
||
print '''\
|
||
From: %(name)s <%(address)s>
|
||
To: %(people)s''' % vars
|
||
if replyto:
|
||
print 'Reply-To: %s' % replyto
|
||
print '''\
|
||
Subject: %(subject)s
|
||
Date: %(date)s
|
||
X-Mailer: Python syncmail %(version)s <http://sf.net/projects/cvs-syncmail>
|
||
''' % vars
|
||
s.write(sys.stdin.read())
|
||
# append the diffs if available
|
||
print
|
||
for entry in entries:
|
||
print calculate_diff(entry, contextlines)
|
||
finally:
|
||
sys.stdout = sys.__stdout__
|
||
resp = conn.sendmail(address, people, s.getvalue())
|
||
conn.close()
|
||
os._exit(0)
|
||
|
||
|
||
|
||
class CVSEntry:
|
||
def __init__(self, name, revision, timestamp, conflict, options, tagdate):
|
||
self.name = name
|
||
self.revision = revision
|
||
self.timestamp = timestamp
|
||
self.conflict = conflict
|
||
self.options = options
|
||
self.tagdate = tagdate
|
||
|
||
def get_entry(prefix, mapping, line, filename):
|
||
line = line.strip()
|
||
parts = line.split("/")
|
||
_, name, revision, timestamp, options, tagdate = parts
|
||
key = namekey(prefix, name)
|
||
try:
|
||
entry = mapping[key]
|
||
except KeyError:
|
||
if revision == "0":
|
||
revision = None
|
||
if timestamp.find("+") != -1:
|
||
timestamp, conflict = tuple(timestamp.split("+"))
|
||
else:
|
||
conflict = None
|
||
entry = CVSEntry(key, revision, timestamp, conflict,
|
||
options, tagdate)
|
||
mapping[key] = entry
|
||
return entry
|
||
|
||
def namekey(prefix, name):
|
||
if prefix:
|
||
return os.path.join(prefix, name)
|
||
else:
|
||
return name
|
||
|
||
def load_change_info(prefix=None):
|
||
if prefix is not None:
|
||
entries_fn = os.path.join(prefix, "CVS", "Entries")
|
||
else:
|
||
entries_fn = os.path.join("CVS", "Entries")
|
||
entries_log_fn = entries_fn + ".Log"
|
||
mapping = {}
|
||
f = open(entries_fn)
|
||
while 1:
|
||
line = f.readline()
|
||
if not line:
|
||
break
|
||
## if line.strip() == "D":
|
||
## continue
|
||
# we could recurse down subdirs, except the Entries.Log files
|
||
# we need haven't been written to the subdirs yet, so it
|
||
# doesn't do us any good
|
||
## if line[0] == "D":
|
||
## name = line.split("/")[1]
|
||
## dirname = namekey(prefix, name)
|
||
## if os.path.isdir(dirname):
|
||
## m = load_change_info(dirname)
|
||
## mapping.update(m)
|
||
if line[0] == "/":
|
||
# normal file
|
||
get_entry(prefix, mapping, line, entries_fn)
|
||
# else: bogus Entries line
|
||
f.close()
|
||
if os.path.isfile(entries_log_fn):
|
||
f = open(entries_log_fn)
|
||
while 1:
|
||
line = f.readline()
|
||
if not line:
|
||
break
|
||
if line[1:2] != ' ':
|
||
# really old version of CVS
|
||
break
|
||
entry = get_entry(prefix, mapping, line[2:], entries_log_fn)
|
||
parts = line.split("/")[1:]
|
||
if line[0] == "A":
|
||
# adding a file
|
||
entry.new_revision = parts[1]
|
||
elif line[0] == "R":
|
||
# removing a file
|
||
entry.new_revision = None
|
||
f.close()
|
||
for entry in mapping.values():
|
||
if not hasattr(entry, "new_revision"):
|
||
print 'confused about file', entry.name, '-- ignoring'
|
||
del mapping[entry.name]
|
||
return mapping
|
||
|
||
def load_branch_name():
|
||
tag_fn = os.path.join("CVS", "Tag")
|
||
if os.path.isfile(tag_fn):
|
||
f = open(tag_fn)
|
||
line = f.readline().strip()
|
||
f.close()
|
||
if line[:1] == "T":
|
||
return line[1:]
|
||
return None
|
||
|
||
# scan args for options
|
||
def main():
|
||
# XXX Should really move all the options to an object, just to
|
||
# avoid threading so many positional args through everything.
|
||
try:
|
||
opts, args = getopt.getopt(
|
||
sys.argv[1:], 'hC:cuS:R:qf:m:',
|
||
['fromhost=', 'context=', 'cvsroot=', 'mailhost=',
|
||
'subject-prefix=', 'reply-to=',
|
||
'help', 'quiet'])
|
||
except getopt.error, msg:
|
||
usage(1, msg)
|
||
|
||
# parse the options
|
||
contextlines = 2
|
||
verbose = 1
|
||
subject_prefix = ""
|
||
replyto = None
|
||
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 ('-S', '--subject-prefix'):
|
||
subject_prefix = arg
|
||
elif opt in ('-R', '--reply-to'):
|
||
replyto = arg
|
||
elif opt in ('-q', '--quiet'):
|
||
verbose = 0
|
||
elif opt in ('-f', '--fromhost'):
|
||
fromhost = arg
|
||
elif opt in ('-m', '--mailhost'):
|
||
global MAILHOST
|
||
MAILHOST = 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 = subject_prefix + args[0]
|
||
specs = args[0].split()
|
||
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 specs[-3:] == ['-', 'Imported', 'sources']:
|
||
print 'Not sending email for imported sources.'
|
||
return
|
||
|
||
branch = load_branch_name()
|
||
changes = load_change_info()
|
||
|
||
if verbose:
|
||
print 'Mailing %s...' % COMMASPACE.join(people)
|
||
print 'Generating notification message...'
|
||
blast_mail(subject, people, changes.values(),
|
||
contextlines, fromhost, replyto)
|
||
if verbose:
|
||
print 'Generating notification message... done.'
|
||
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|
||
sys.exit(0)
|