#!/usr/bin/python import sys,re,os,string,urllib,httplib ################################################################################ # Configuration ################################################################################ # Determine repository root pin = os.popen("svn info|grep 'Repository Root'|sed -e 's/^.\+: //'", "r") REPOSITORY = pin.read().rstrip()+"/" pin.close() print "Repository: %s" % (REPOSITORY) ################################################################################ # Parse command-line arguments ################################################################################ def help(exitvalue = 0): print "Usage: batchmerge [options] \n" print "Options:" print "\t-m\tOnly merge. (For 'single' command.)" print "\t-c\tOnly commit. (For 'single' command.)" print "\t-html\tHTML output. (For 'log' command.)" print "\t-author\tHTML output. (Include author in HTML log.)" print "\t-milestone \tList tickets in milestone (For 'log' command.)" print "\nCommands:" print "massmerge [] " print " - Merges changesets listed in the configuration" print " file. The file is in svn log (text) format." print " You can comment out unwanted changesets." print " Merge is stopped on conflict." print " If you give the optional parameter," print " merge is started from the changeset number. " print "single - Merges a single changeset. If -m is given," print " only merge is done. If -c is given, only commit is done." print "revert - Reverts all changes made to repository except" print " changes in this program and the merge files." print "log - Prints a ChangeLog as it will appear in the" print " commit log. If -html option is given," print " the log is printed in a HTML format" print " suitable for the Release Notes." print "commit - Commits all changes, except changes to this" print " program and merge files. The commit log" print " comment includes list of all changesets" print " listed in the configuration. The " print " is the branch name, e.g., \"5.2\".\n" print "Common parameters:" print " - Configuration file (svn text log format)." print " - Source branch relative to repository URI." print " - Target branch relative to repository URI." print "You must run the command in the root directory of the branch." print "The program file contains some basic configuration parameters." sys.exit(exitvalue) ################################################################################ # Globals ################################################################################ tickets = {} ################################################################################ # Utility Functions ################################################################################ def command(cmd, dryrun=0): print cmd if not dryrun: if os.system(cmd): print "Command failed, exiting." sys.exit(1) else: print "Dry run - not executing." def listChangedFiles(): # Get Svn status pin = os.popen("svn st", "r") lines = pin.readlines() pin.close() changed = [] for line in lines: # Remove trailing newline line = line[:-1] # Extract the file state and name (filestate, filename) = re.split(r'[ \+]+', line.lstrip()) # Ignore files in build directory if (filename.startswith("build/merge/") \ or filename.startswith("build/bin/mergetool.py") \ or filename.startswith("build/testing")) \ and filestate == "M": continue # File is changed if it is not local if filestate != "?": changed.append(filename) return changed # Retrieves ticket summary string with HTTP # Returns: (summary, milestone) def fetchSummary(ticketno): params = urllib.urlencode({'format': 'tab'}) conn = httplib.HTTPConnection("dev.itmill.com") conn.request("GET", "/ticket/%d?%s" % (ticketno, params) ) response = conn.getresponse() data = response.read() conn.close() lines = data.split("\n") data = reduce(lambda x,y: x+"\n"+y, lines[1:]) #cols = lines[1].split("\t") cols = data.split("\t") return (cols[1],cols[8]) # Adds summary to ticket number, unless the context already has it # Returns: (summary, milestone) def addSummary(m): ticketnum = int(m.group(1)) context = m.group(2) if re.match(" *\(", context): # The context already has ticket summary return "#%d%s" % (ticketnum, context) (summary,milestone) = fetchSummary(ticketnum) # Remove possible " quotation from the summary if summary.startswith('"'): summary = summary.strip('"') summary = summary.replace('""', '"') # Add summaries to further ticket numbers recursively context = re.sub(r'#([0-9]+)(.*)', addSummary, context) return "#%s (%s) %s" % (ticketnum, summary, context) ################################################################################ # Change ################################################################################ class Ticket: def __init__(self, id, summary=None, milestone=None): self.id = id self.summary = summary self.milestone = milestone def fetchData(self): (summary, milestone) = fetchSummary(self.id) self.summary = summary self.milestone = milestone ################################################################################ # Change ################################################################################ class Change: def __init__(self, id, undo=0, author=""): self.id = id self.author = author self.comment = "" self.undo = undo self.tickets = [] def addCommentLine(self, line): self.comment += line def merge(self, trunkURI, dryrun=0): drycmd = "" if dryrun: drycmd = "--dry-run" # Handle negative merge mergesign = "" if self.undo: mergesign = "-" # Build the merge command cmd = "svn merge --non-interactive %s -c %s%d %s" % (drycmd, mergesign, self.id, trunkURI) print cmd # Run the merge command pin = os.popen(cmd, "r") lines = pin.readlines() pin.close() # Parse the lines for conflicts conflicts = 0 for line in lines: print line[:-1] # Check for conflict if line.startswith("C"): conflicts += 1 # Check for skipped file elif line.startswith("Skipped"): conflicts += 1 filename = line[8:-1] # Simply exit if there was any problem if conflicts > 0: print "Problems detected. Exiting." sys.exit(1) def fetchComment(self, trunkURI): cmd = "svn log -r %d %s" % (self.id, trunkURI) # Run the log command pin = os.popen(cmd, "r") lines = pin.readlines() pin.close() STATE_START = 0 STATE_COMMENT = 1 comment = None state = STATE_START for line in lines: if state == STATE_START: if line == "\n": state = STATE_COMMENT elif state == STATE_COMMENT: if line.startswith("-----------------"): self.comment = comment return comment elif comment: comment += "\n" + line.rstrip("\n") else: comment = line.rstrip("\n") self.comment = comment return comment def commit(self): # Write the log comment to a temporary file logtmpname = "/tmp/merge-single-%d.log" % (os.getpid()) fout = open(logtmpname, "w") fout.write(self.comment) fout.close() # Get listo files = listChangedFiles() if len(files) == 0: print "Error: Will not do empty commit." sys.exit(1) # Write the list of files to be committed to a temporary file changedfiles = ("\n".join(files)) + "\n" targettmpname = "/tmp/merge-targets-%d.txt" % (os.getpid()) fout = open(targettmpname, "w") fout.write(changedfiles) fout.close() print changedfiles, command("svn commit --file %s --targets %s" % (logtmpname, targettmpname)) command("rm %s %s" % (logtmpname, targettmpname)) def getNumber(self): return self.id def getComment(self): return self.comment def getUndo(self): return self.undo def getAuthor(self): return self.author def isForMilestone(self, milestone): return self.author def addSummary(self, m, target_milestone=None): ticketnum = int(m.group(1)) context = m.group(2) if re.match(" *\(", context): # The context already has ticket summary return "#%d%s" % (ticketnum, context) # Check for cached ticket if tickets.has_key(ticketnum): summary = tickets[ticketnum].summary ticket_milestone = tickets[ticketnum].milestone else: # Fetch ticket from server and add to cache (summary,ticket_milestone) = fetchSummary(ticketnum) tickets[ticketnum] = Ticket(ticketnum,summary,ticket_milestone) self.tickets.append(ticketnum); # Remove possible " quotation from the summary if summary.startswith('"'): summary = summary.strip('"') summary = summary.replace('""', '"') ticketnum = "#%s" % (ticketnum) # Emphasize tickets matching the target milestone if target_milestone: if ticket_milestone.find(target_milestone) != -1: ticketnum = "%s" % (ticketnum) # Add summaries to further ticket numbers recursively context = re.sub(r'#([0-9]+)(.*)', lambda m: self.addSummary(m, target_milestone=target_milestone), context) return "%s (%s) %s" % (ticketnum, summary, context) def registerTicket(self, m, ticketNumbers): ticketNumbers[int(m.group(1))] = 1 return "" # Returns a list of ticket numbers referenced by this change def listTickets(self): ticketNumbers = {} re.sub(r'#([0-9]+)', lambda m: self.registerTicket(m,ticketNumbers=ticketNumbers), self.comment) return ticketNumbers.keys() ################################################################################ # Read configuration file ################################################################################ class Configuration: def __init__(self, cfgfilename, startfrom=0): self.changes = [] self.readConfig(cfgfilename, startfrom) def readConfig(self, cfgfilename, startfrom=0): fin = open(cfgfilename, "r") content = fin.readlines() fin.close() # Parse configuration currentChange = None skipChange = 0 for line in content: m_changestart = re.match(r'(-?)r([0-9]+)', line) m_endofchange = re.match(r'------+', line) m_emptyline = re.match(r'^$', line) if m_changestart: # Parse negative merge undo = 0 if m_changestart.group(1) == "-": undo = 1 # Get changeset number id = int(m_changestart.group(2)) # Skip the target if its number is too small if startfrom != 0 and id < startfrom: skipChange = 1 # Get the author author = re.sub(r'\@.+', '', line.split("|")[1].strip()) currentChange = Change(id, undo=undo, author=author) elif m_endofchange: # Register changeset, unless it is marked # for skipping. if currentChange: if skipChange: skipChange = 0 else: self.changes.append(currentChange) currentChange = None elif m_emptyline: pass else: if currentChange: currentChange.addCommentLine(line) def massMerge(self, trunkURI, dryrun=0, allatonce=0): if not allatonce: # Merge one changeset at a time for change in self.changes: change.merge(trunkURI, dryrun=dryrun) else: # What is the first changeset in the merge? smallest = 99999999 for change in self.changes: if change.getNumber() < smallest: smallest = change.getNumber() drycmd = "" if dryrun: drycmd = "--dry-run" # Merge from the first changeset to HEAD cmd = "svn merge --non-interactive %s -r %d:HEAD %s" % (drycmd, smallest, trunkURI) print cmd command(cmd) def createLogComment(self): # Create a log comment that lists all merged changesets with # comments logcomment = "Merge from %s to %s:\n" % (sourcebranch, targetbranch) for change in self.changes: changeno = change.getNumber() changecomment = change.getComment().rstrip("\n") if change.getUndo(): logcomment += "Reverted [%d] merge: %s\n" % (changeno, changecomment) else: logcomment += "Merged [%d]: %s\n" % (changeno, changecomment) return logcomment def logHtml(self, author=0, milestone=None): # In author inclusion mode, include some styles to make a printout look better if author: print "\n\n\n" # Print header print "" authorcolumnheader = "" if author: authorcolumnheader = "" print " %s" % (authorcolumnheader) # Print body: the changes for change in self.changes: changeno = change.getNumber() changecomment = change.getComment().rstrip("\n") changeref = "[%d]" % (changeno) # Handle merge undo markup if change.getUndo(): changeref = "%s" % (changeref) changecomment = "%s" % (changecomment) changecomment = "Reverted a change: "+changecomment authorcolumn = "" if author: authorcolumn = "" % (change.getAuthor()) # Add ticket summary after ticket number, if missing # TODO: this handles only one changecomment = re.sub(r'#([0-9]+)(.*)', lambda m: change.addSummary(m, target_milestone=milestone), changecomment, 100) # item = re.sub(r'#([0-9]+)(.*)', '#\\1 (SUMMARY) \\2', item) # Change ticket numbers to references to tickets changecomment = re.sub(r'#([0-9]+)', '#\\1', changecomment) # Change changeset numbers to references to changesets changecomment = re.sub(r'\[([0-9]+)\]', '[\\1]', changecomment) changeref = re.sub(r'\[([0-9]+)\]', '[\\1]', changeref) # See if any of the tickets have milestone under work. if milestone: for ticketnum in change.tickets: ticket = tickets[ticketnum] if ticket.milestone.find(milestone) != -1: changeref = "%s" % (changeref) # Make basic HTML formatting item = " %s" % (changeref, changecomment, authorcolumn) print item sys.stdout.flush() print "
Author
#Changeset Comment
%s
%s:%s
" # Prints a commit log to standard output def log(self, sourcebranch, targetbranch, html=0, author=0, milestone=None): if html: self.logHtml(author=author,milestone=milestone) return sys.stdout.write(self.createLogComment()) def commit(self, sourcebranch, targetbranch, dryrun=0): logcomment = self.createLogComment() # Write the log comment to a temporary file logtmpname = "/tmp/massmerge-pid-%d.log" % (os.getpid()) fout = open(logtmpname, "w") fout.write(logcomment) fout.close() # Write the list of files to be committed to a temporary file changedfiles = "\n".join(listChangedFiles()) targettmpname = "/tmp/massmerge-targets-%d.txt" % (os.getpid()) fout = open(targettmpname, "w") fout.write(changedfiles) fout.close() if dryrun: print "Log:" os.system("cat %s" % (logtmpname)) print "\nChanged Files:" os.system("cat %s" % (targettmpname)) print "\n" command("svn commit --file %s --targets %s" % (logtmpname, targettmpname), dryrun=dryrun) command("rm %s %s" % (logtmpname, targettmpname)) def listTickets(self): fixed = {} for change in self.changes: changeno = change.getNumber() changeTickets = change.listTickets() if len(changeTickets)>0 and change.comment.lower().find("fix") != -1: for ticket in changeTickets: fixed[ticket] = 1 fixedlist = fixed.keys() fixedlist.sort() # print "Fixed:", fixedlist print "
    " for ticketNum in fixedlist: if not tickets.has_key(ticketNum): ticket = Ticket(ticketNum) ticket.fetchData() tickets[ticketNum] = ticket ticket = tickets[ticketNum] # print "%d: %s" % (ticket.id, ticket.summary) print "
  • #%d: %s
  • " % (ticket.id, ticket.id, ticket.summary) sys.stdout.flush() print "
" ################################################################################ # Commands ################################################################################ # Command: revert def commandRevert(): # Get Svn status pin = os.popen("svn st", "r") lines = pin.readlines() pin.close() reverted = [] removed = [] for line in lines: # Remove trailing newline line = line[:-1] # Extract the file state and name (filestate, filename) = re.split(r'[ \+]+', line) # Ignore files in build directory if (filename.startswith("build/merge/") \ or filename.startswith("build/bin/")) \ and filestate == "M": continue # Added files are simply deleted if filestate != "?": reverted.append(filename) # Added files have to be removed in addition to reverting if filestate == "A": removed.append(filename) # Remove conflict choises elif filestate == "?" and \ (filename.find(".merge-left.r") != -1 or \ filename.find(".merge-right.r") != -1): removed.append(filename) # Revert files marked for reverting donework = 0 if len(reverted) > 0: files = " ".join(reverted) command("svn revert -R %s" % (files)) donework = 1 # Remove files marked for deletion if len(removed) > 0: files = " ".join(removed) command("rm %s" % (files)) donework = 1 if not donework: print "Nothing to do." # Command: massmerge def commandMassmerge(cfgfilename, sourceuri, startfrom, dryrun=0, allatonce=0): cfg = Configuration(cfgfilename, startfrom=startfrom) cfg.massMerge(sourceuri, dryrun=dryrun, allatonce=allatonce) # Command: single def commandSingle(trunkuri, changeset, sourcebranch, targetbranch, onlymerge=0, onlycommit=0): change = Change(changeset) print "Found changeset with log comment:\n "+change.fetchComment(trunkuri) + "\n" change.merge(trunkuri, dryrun=onlycommit) if onlycommit: print "Got file list." else: print "Merge successful." # Change the comment change.comment = "Merged [%d] from %s to %s branch: %s" % (change.id, sourcebranch, branchname, change.comment) print "\nLog comment: \"%s\"" % (change.comment) if not onlymerge: print "Committing." change.commit() # Command: commit def commandCommit(cfgfilename, sourcebranch, targetbranch, dryrun=0): cfg = Configuration(cfgfilename) cfg.commit(sourcebranch, targetbranch, dryrun=dryrun) # Command: log def commandLog(cfgfilename, sourcebranch, targetbranch, html=0, author=0, milestone=None): cfg = Configuration(cfgfilename) cfg.log(sourcebranch, targetbranch, html=html, author=author, milestone=milestone) # Command: tickets def commandTickets(cfgfilename): cfg = Configuration(cfgfilename) cfg.listTickets() ################################################################################ # Main Program ################################################################################ # Handle switches dryrun = 0 html = 0 html_author = 0 html_milestone = None onlymerge = 0 onlycommit = 0 all = 0 while len(sys.argv)>1 and sys.argv[1][0] == '-': if sys.argv[1] == "-d": dryrun = 1 del sys.argv[1:2] elif sys.argv[1] == "-html": html = 1 del sys.argv[1:2] elif sys.argv[1] == "-author": html_author = 1 del sys.argv[1:2] elif sys.argv[1] == "-milestone": html_milestone = sys.argv[2] del sys.argv[1:3] elif sys.argv[1] == "-m": onlymerge = 1 del sys.argv[1:2] elif sys.argv[1] == "-c": onlycommit = 1 del sys.argv[1:2] elif sys.argv[1] == "-all": all = 1 del sys.argv[1:2] else: print "Invalid option '%s'." % (sys.argv[1]) sys.exit(1) if len(sys.argv) < 2: help(1) # Handle commands if sys.argv[1] == "revert": commandRevert() elif (len(sys.argv) == 4 or len(sys.argv)==5) and sys.argv[1] == "massmerge": cfgfilename = sys.argv[2] sourcebranch = sys.argv[3] startfrom = None if len(sys.argv)>4: startfrom = int(sys.argv[4]) commandMassmerge(cfgfilename, sourceuri=REPOSITORY+sourcebranch, startfrom=startfrom, dryrun=dryrun, allatonce=all) elif len(sys.argv) == 5 and sys.argv[1] == "single": changeset = int(sys.argv[2]) sourcebranch = sys.argv[3] targetbranch = sys.argv[4] commandSingle(REPOSITORY+sourcebranch, changeset, targetbranch, onlymerge=onlymerge, onlycommit=onlycommit) elif len(sys.argv) == 5 and sys.argv[1] == "commit": cfgfilename = sys.argv[2] sourcebranch = sys.argv[3] targetbranch = sys.argv[4] commandCommit(cfgfilename, sourcebranch, targetbranch, dryrun=dryrun) elif len(sys.argv) == 5 and sys.argv[1] == "log": cfgfilename = sys.argv[2] sourcebranch = sys.argv[3] targetbranch = sys.argv[4] commandLog(cfgfilename, sourcebranch, targetbranch, html=html, author=html_author, milestone=html_milestone) elif len(sys.argv) == 3 and sys.argv[1] == "tickets": cfgfilename = sys.argv[2] commandTickets(cfgfilename) else: help(1)