#!/opt/bin/python

# Automates batch operations for svn and Java.  Only tested on Mac OS X and
# Linux (sliderule.csse); almost certainly won't work on Windows.
#
# by Curt Clifton, December 2008

from __future__ import with_statement
import sys
import os
from string import Template
from subprocess import Popen, PIPE, STDOUT

# Nearly constants
menuWidth = 72
rootURL = 'http://svn.csse.rose-hulman.edu/repos'
testClassesListing = 'unitTestMap.txt'
lockFileName = '.batchEclipseLock'
batchLogFileName = 'batchLogFile.txt'

# ----------------------------------------------------------------
# Term parameters
# stripped from each repo name to get short name:
repoNamePrefix = 'csse120-200930-'
repoFileName = repoNamePrefix + 'repos.txt'
# For Testing: repoFileName = 'littleRepos.txt'
scriptDir = sys.path[0]

# Other global variables
projectName = ''
testClasses = ''
workingDir = os.getcwd()
menuItems = []
repos = []
with open(scriptDir + '/' + testClassesListing, 'r') as f:
    projects = map(lambda l: l.strip().split(',')[0], f.readlines())
    projects = filter(lambda x: x != '' and not x.startswith('#'), projects)
def setRepos(withPrinting=False):
    global repos
    repos = []
    with open(scriptDir + '/' + repoFileName, 'r') as f:
        if withPrinting:
            print 'Repositories to use:'
            for l in f:
                line = l.strip()
                repos.append(line)
                print line
        else:
            repos = f.readlines()
            repos = map(lambda l: l.strip(), repos)
setRepos()

# ----------------------------------------------------------------
def truncateMiddle(d, length):
    """Returns a truncated version of d by omitting characters in the middle
    if d is longer then length."""
    splitLen = length / 2
    if len(d) < length:
        return d
    else:
        return d[:splitLen] + '...' + d[-(length-3-splitLen):]

# ----------------------------------------------------------------
def displayMenu(prompt, items, allowOthers = False):
    """
    Displays a numbered menu where each item is an item of the
    sequence items. Returns a tuple consisting of the chosen item number
    (or None if other text is enter) and the text of the choice.
    
    Presents the given prompt.  Continues presenting the prompt until
    the user picks an item from the list.  If allowOthers is True, then
    also lets user enter a non-numeric string which is the text returned
    to the caller.  Otherwise the element of items corresponding to the
    entered number is returned.
    """
    for i,t in enumerate(items):
        print "%2d - %s" % (i, t)
    print
    
    choice = ''
    while choice == '':
        inputValue = raw_input(prompt).strip()
        try:
            inputValue = int(inputValue)
            choice = items[inputValue]
            
        except ValueError:
            if allowOthers:
                choice = inputValue
                inputValue = None
            else:
                print '\a*** you must choose a number from the list above ***'
                choice = ''
        except IndexError:
            print '\a*** only numbers in the list above are valid ***'

    return inputValue, choice

# ----------------------------------------------------------------
def forEachRepo(command, msg='', create=False, withCapture=False, suppressOutput=False):
    """Iterates over each repository executing the given command.  The command
    is executed with the current working directory set to the local working
    directory.  The command is a template string.  Within the template string:
    
        ${rootURL} is replaced with the root URL of the repository
        ${repo} is replaced with the full repository name
        ${proj} is replaced with the project name
        ${shortName} is replaced with the short repository name, that is
                        the full repository name with the course/term prefix
                        removed
        ${msg} is replaced with the msg argument
        ${scriptDir} is replaced with the script directory
        ${cwd} is replaced with the working directory
        ${testClasses} is replaced with a space-delimited list of the fully
                          qualified names of the JUnit test classes
                      
    msg is the replacement for the ${msg} placeholder in the command
    
    create specifies whether the local project subdirectory should be created
    
    withCapture specifies whether the output (stdout and stderr) of the command
        should be captured to batchLogFileName, lines of captured data are 
        prefixed with the short repository name, a colon, and a space
    
    suppressOutput specified whether the output of the command should be 
        suppressed on the console
    """
    global projectName
    replacements = {}
    replacements['rootURL'] = rootURL
    replacements['proj'] = projectName
    replacements['msg'] = "'" + msg + "'"
    replacements['scriptDir'] = scriptDir
    replacements['cwd'] = workingDir
    replacements['testClasses'] = testClasses
    commandTemplate = Template(command)
    
    if command.find('${testClasses}') >= 0 and len(testClasses) == 0:
        print '\a\n*** No test classes listed for project "' + projectName + '" ***\n'
        return

    # Creates project specific subdirectory if requested
    if create:
        if os.access(projectName,os.F_OK):
            print '\a\n*** Local project subdirectory already exists ***\n'
            return
        else:
            os.mkdir(projectName)
            if not os.access(projectName,os.F_OK):
                print '\a\n*** Unable to create local project subdirectory ***\n'
                return

    # Verifies project subdirectory exists
    if not os.access(projectName,os.F_OK):
        print '\a\n*** Unable to access local project subdirectory ***\n'
        return
        
    # Functional goodness for printing
    if suppressOutput:
        def printHelper(x):
            pass
    else:
        def printHelper(x):
            print x

    # Iterates over the repositories
    if withCapture:
        captureFile = open(batchLogFileName, 'w')
    for repo in repos:
        replacements['repo'] = repo
        replacements['shortName'] = repo.replace(repoNamePrefix, '')
        cmd = commandTemplate.substitute(replacements)

        printHelper('-*' * (menuWidth / 2))
        shortNameLength = len(replacements['shortName'])
        padRepeat = (menuWidth - shortNameLength) / 2 / 4
        printHelper('--> ' * padRepeat + replacements['shortName'].upper() + ' <--' * padRepeat)
        printHelper(truncateMiddle(cmd, menuWidth))

        os.chdir(projectName)
        p = Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=STDOUT, close_fds=True)
        (cmdInput, cmdOutput) = (p.stdin, p.stdout)
        outputText = cmdOutput.readlines()
        cmdInput.close()
        cmdOutput.close()
        for line in outputText:
            printHelper(line.strip())
            if withCapture:
                captureFile.write(replacements['shortName'])
                captureFile.write(': ')
                captureFile.write(line)
        os.chdir('..')
    if withCapture:
        captureFile.close()
    

# ----------------------------------------------------------------
# Creates a dictionary of menu items and functions to execute:
def checkout():
    forEachRepo('svn checkout ${rootURL}/${repo}/${proj} ${shortName}', 
                create=True)
    return False
menuItems.append(('Checkout from SVN',checkout))
    
def update():
    forEachRepo('svn update ${shortName}')
    return False
menuItems.append(('Update from SVN',update))
    
def commit():
    if os.access(projectName + '/' + lockFileName,os.F_OK):
        print '\a\n*** Remove Eclipse metadata before committing. ***\n'
        return False
    message = raw_input('Enter the commit message: ').strip().replace("'", '"')
    forEachRepo('svn commit ${shortName} -m ${msg}', msg=message)
    return False
menuItems.append(('Commit to SVN',commit))

def checkLog():
    forEachRepo('svn log --limit 5 ${shortName}')
    return False
menuItems.append(('Get data on last 5 commits',checkLog))
    
def generateCommitTimeList():
    forEachRepo('svn info ${shortName} | tail -n 4 | head -n 3', withCapture=True, suppressOutput=True)
    # Processes batchLogFileName to create sorted list
    # Output format:
    # <shortName>: committed by <committer> on <commitDate>
    #  - or -
    # <shortName>: unexpected data format
    results = [] # list of pairs of (commitDate or None, formatted output line)
    unexpected = True
    shortName = 'unknown'
    with open(batchLogFileName, 'r') as f:
        for line in f:
            # Extracts shortName and svn info from batch file line
            line = line.strip()
            spl = line.split(':',1)
            if len(spl) == 2:
                name, rest = spl
                rest = rest.lstrip()
            else:
                # Handles unexpected data (poorly)
                name = spl[0]
                rest = spl[0]
                
            # Handles possibilities for rest
            if rest.startswith('Last Changed Author: '):
                # Start of block
                unexpected = False
                shortName = name
                committer = rest[21:]
                commitDate = 'unknown'
            elif rest.startswith('Last Changed Date: '):
                # End of block
                if name == shortName:
                    commitDate = rest[19:]
                    outLine = '%s: committed by %s on %s' % (shortName, 
                                                             committer, 
                                                             commitDate)
                    results.append((commitDate, outLine))
                else:
                    unexpected = True
            elif rest.startswith('Last Changed Rev: '):
                # Ignored data
                if name != shortName:
                    unexpected = True
            else:
                # Unexpected data
                unexpected = True
                
            # Records unexpected data as soon as it's detected
            if unexpected:
                results.append((None, 
                                '%s: unexpected data format' % (shortName)))
                unexpected = False
                shortName = 'unknown'
                
    # Sorts and prints results
    results.sort(key = lambda x: x[0])
    print "\nMost recent commit info, in chronological order:"
    for k,v in results:
        print v
    print '\n'
    return False
menuItems.append(('Generate list of commits, sorted by commit time',generateCommitTimeList))

def compile():
    forEachRepo("mkdir ${shortName}/bin")
    forEachRepo("javac -cp '${scriptDir}/junit-4.5.jar:${shortName}/bin' -d '${shortName}/bin' `find ${shortName} -type f -name '*.java'`")
    return False
menuItems.append(('Compile student code',compile))    
    
def runTests():
    forEachRepo("cd ${shortName} && java -cp '${scriptDir}/junit-4.5.jar:bin' org.junit.runner.JUnitCore ${testClasses}")
    return False
menuItems.append(('Run JUnit tests',runTests))

def convertToEclipse():
    # Change project names to short names by editing the Eclipse metadata
    forEachRepo("sed -i~ 's:<name>${proj}</name>:<name>${shortName}</name>:' '${shortName}/.project'")
    # Add lock file to block commits
    lockFile = projectName + '/' + lockFileName
    if os.access(projectName,os.F_OK):
        os.system('touch %s' % (lockFile))
    if not os.access(lockFile, os.F_OK):
        print '\a\n*** DANGER: Unable to create lock file.  Do NOT commit! ***\n'
    else:
        print """\a\a\a
        In Eclipse, use Import to batch import all of the student
        projects. Be sure to NOT copy the projects into your workspace
        or you won't be able to add comments to their code.
        
        WARNING!!! DO NOT COMMIT TO SVN FROM WITHIN ECLIPSE.  YOU COULD
        WEDGE THE STUDENT'S VERSION OF THEIR PROJECT.
        """
    return False
menuItems.append(('Add Eclipse metadata (allows importing all projects into Eclipse)',convertToEclipse))

def convertFromEclipse():
    # Revert project names within .project files
    forEachRepo("sed -i~ 's:<name>${shortName}</name>:<name>${proj}</name>:' '${shortName}/.project'")
    # Remove lock file to enable commits
    lockFile = projectName + '/' + lockFileName
    if os.access(lockFile,os.F_OK):
        os.system('rm -f %s' % (lockFile))
        if os.access(lockFile,os.F_OK):
            print '\a\n*** Unable to remove lock file: %s ***\n' % (lockFileName)
            return False
    return False
menuItems.append(('Remove added Eclipse metadata (required before committing)',convertFromEclipse))

def zipForDownload():
    if os.access(projectName,os.F_OK):
        os.system('zip -r %s.zip %s' % (projectName, projectName))
        if not os.access(projectName + '.zip',os.F_OK):
            print '\a\n*** Unable to create zip file ***\n'
    return False
menuItems.append(('Zip local project subdirectory for download', zipForDownload))

def deleteProjects():
    if os.access(projectName,os.F_OK):
        os.system('rm -Rf %s' % (projectName))
        if os.access(projectName,os.F_OK):
            print '\a\n*** Unable to delete local project subdirectory ***\n'
    return False
menuItems.append(('Delete local project subdirectory (and your copies of student projects)',deleteProjects))
    
def setProjectNameFromInput():
    global projectName, testClasses
    projectPrompt = 'Enter the Eclipse project name (or number from list above): '
    n, projectName = displayMenu(projectPrompt, projects, allowOthers=True)
    testClasses = ''
    with open(scriptDir + '/' + testClassesListing, 'r') as f:
        for l in f:
            if l.startswith(projectName):
                lSplit = l.strip().split(',')
                if len(lSplit) != 2:
                    print '\a\n*** Bad format in %s for project "%s" ***\n' % (testClassesListing, projectName)
                else:
                    testClasses = lSplit[1]
    if testClasses == '':
        print '\a\n*** No unit tests listed for project "%s" ***\n' % (projectName)
    return False
menuItems.append(('Enter another project name',setProjectNameFromInput))

def changeRepoListFile():
    global repoFileName, repos
    # Presents list of .txt files in script directory
    fileList = os.listdir(scriptDir)
    fileList = filter(lambda x: x.endswith('.txt'), fileList)
    fileList.sort()
    prompt = """
Enter number of text file that lists the repositories 
in which you are interested: """
    n, repoFileName = displayMenu(prompt, fileList)
    setRepos(True)
    return False
menuItems.append(('Change to a different set of repositories',changeRepoListFile))

def exitProgram():
    print "Buh-bye.  <waves />"
    return True
menuItems.append(('Quit',exitProgram))


# ----------------------------------------------------------------
# Display prompt for project name
print """
Welcome to the CSSE 230 batch SVN and JUnit script.
by Curt Clifton, Dec. 2008
"""
setProjectNameFromInput()

done = False
while not done:
    print '-' * menuWidth
    print 'Project Name:', projectName
    print 'Working Dir.:', truncateMiddle(workingDir, menuWidth - 14)
    print 'Repositories:', repoFileName
    print 'Test Classes:', truncateMiddle(testClasses, menuWidth - 14)
    print '-' * menuWidth
    
    choice, t = displayMenu('\nEnter the number of your choice: ', 
                            map(lambda y: y[0], menuItems))
    
    try:
        fn = menuItems[choice][1]
        done = fn()
    except Exception, e:
        raise e