scripts/release/release.py
changeset 1835 3f30b7b14c57
parent 1834 0589bf1395c5
child 1846 ac30e04bcbba
equal deleted inserted replaced
1834:0589bf1395c5 1835:3f30b7b14c57
    62 # release number.
    62 # release number.
    63 MELANGE_RELEASE_RE = re.compile(r'\d-\d-\d{8}p\d+')
    63 MELANGE_RELEASE_RE = re.compile(r'\d-\d-\d{8}p\d+')
    64 
    64 
    65 
    65 
    66 class Error(error.Error):
    66 class Error(error.Error):
    67     pass
    67   pass
    68 
    68 
    69 
    69 
    70 class AbortedByUser(Error):
    70 class AbortedByUser(Error):
    71     """The operation was aborted by the user."""
    71   """The operation was aborted by the user."""
    72 
    72 
    73 
    73 
    74 class ObstructionError(Error):
    74 class ObstructionError(Error):
    75     """An operation was obstructed by existing data."""
    75   """An operation was obstructed by existing data."""
    76 
    76 
    77 
    77 
    78 class ExpectationFailed(Error):
    78 class ExpectationFailed(Error):
    79     """An unexpected state was encountered by an automated step."""
    79   """An unexpected state was encountered by an automated step."""
    80 
    80 
    81 
    81 
    82 class FileAccessError(Error):
    82 class FileAccessError(Error):
    83     """An error occured while accessing a file."""
    83   """An error occured while accessing a file."""
    84 
    84 
    85 
    85 
    86 def confirm(prompt, default=False):
    86 def confirm(prompt, default=False):
    87     """Ask a yes/no question and return the answer.
    87   """Ask a yes/no question and return the answer.
    88 
    88 
    89     Will reprompt the user until one of "yes", "no", "y" or "n" is
    89   Will reprompt the user until one of "yes", "no", "y" or "n" is
    90     entered. The input is case insensitive.
    90   entered. The input is case insensitive.
    91 
    91 
    92     Args:
    92   Args:
    93       prompt: The question to ask the user.
    93     prompt: The question to ask the user.
    94       default: The answer to return if the user just hits enter.
    94     default: The answer to return if the user just hits enter.
       
    95 
       
    96   Returns:
       
    97     True if the user answered affirmatively, False otherwise.
       
    98   """
       
    99   if default:
       
   100     question = prompt + ' [Yn] '
       
   101   else:
       
   102     question = prompt + ' [yN] '
       
   103   while True:
       
   104     try:
       
   105       answer = raw_input(question).strip().lower()
       
   106     except EOFError:
       
   107       raise AbortedByUser('Aborted by ctrl+D')
       
   108     if not answer:
       
   109       return default
       
   110     elif answer in ('y', 'yes'):
       
   111       return True
       
   112     elif answer in ('n', 'no'):
       
   113       return False
       
   114     else:
       
   115       log.error('Please answer yes or no.')
       
   116 
       
   117 
       
   118 def getString(prompt):
       
   119   """Prompt for and return a string."""
       
   120   try:
       
   121     return raw_input(prompt + ' ').strip()
       
   122   except EOFError:
       
   123     raise AbortedByUser('Aborted by ctrl+D')
       
   124 
       
   125 
       
   126 def getNumber(prompt):
       
   127   """Prompt for and return a number.
       
   128 
       
   129   Will reprompt the user until a number is entered.
       
   130   """
       
   131   while True:
       
   132     value_str = getString(prompt)
       
   133     try:
       
   134       return int(value_str)
       
   135     except ValueError:
       
   136       log.error('Please enter a number. You entered "%s".' % value_str)
       
   137 
       
   138 
       
   139 def getChoice(intro, prompt, choices, done=None, suggest=None):
       
   140   """Prompt for and return a choice from a menu.
       
   141 
       
   142   Will reprompt the user until a valid menu entry is chosen.
       
   143 
       
   144   Args:
       
   145     intro: Text to print verbatim before the choice menu.
       
   146     prompt: The prompt to print right before accepting input.
       
   147     choices: The list of string choices to display.
       
   148     done: If not None, the list of indices of previously
       
   149           selected/completed choices.
       
   150     suggest: If not None, the index of the choice to highlight as
       
   151              the suggested choice.
       
   152 
       
   153   Returns:
       
   154     The index in the choices list of the selection the user made.
       
   155   """
       
   156   done = set(done or [])
       
   157   while True:
       
   158     print intro
       
   159     print
       
   160     for i, entry in enumerate(choices):
       
   161       done_text = ' (done)' if i in done else ''
       
   162       indent = '--> ' if i == suggest else '    '
       
   163       print '%s%2d. %s%s' % (indent, i+1, entry, done_text)
       
   164     print
       
   165     choice = getNumber(prompt)
       
   166     if 0 < choice <= len(choices):
       
   167       return choice-1
       
   168     log.error('%d is not a valid choice between %d and %d' %
       
   169               (choice, 1, len(choices)))
       
   170     print
       
   171 
       
   172 
       
   173 def fileToLines(path):
       
   174   """Read a file and return it as a list of lines."""
       
   175   try:
       
   176     with file(path) as f:
       
   177       return f.read().split('\n')
       
   178   except (IOError, OSError), e:
       
   179     raise FileAccessError(str(e))
       
   180 
       
   181 
       
   182 def linesToFile(path, lines):
       
   183   """Write a list of lines to a file."""
       
   184   try:
       
   185     with file(path, 'w') as f:
       
   186       f.write('\n'.join(lines))
       
   187   except (IOError, OSError), e:
       
   188     raise FileAccessError(str(e))
       
   189 
       
   190 
       
   191 class Subversion(util.Paths):
       
   192   """Wrapper for operations on a Subversion working copy.
       
   193 
       
   194   An instance of this class is bound to a specific working copy
       
   195   directory, and provides an API to perform various Subversion
       
   196   operations on this working copy.
       
   197 
       
   198   Some methods take a 'depth' argument. Depth in Subversion is a
       
   199   feature that allows the creation of arbitrarily shallow or deep
       
   200   working copies on a per-directory basis. Possible values are
       
   201   'none' (no files or directories), 'files' (only files in .),
       
   202   'immediates' (files and directories in ., directories checked out
       
   203   at depth 'none') or 'infinity' (a normal working copy with
       
   204   everything).
       
   205 
       
   206   This class also provides a few static functions that run the 'svn'
       
   207   tool against remote repositories to gather information or retrieve
       
   208   data.
       
   209 
       
   210   Note that this wrapper also doubles as a Paths object, offering an
       
   211   easy way to get or check the existence of paths in the working
       
   212   copy.
       
   213   """
       
   214 
       
   215   def __init__(self, wc_dir):
       
   216     util.Paths.__init__(self, wc_dir)
       
   217 
       
   218   def _unknownAndMissing(self, path):
       
   219     """Returns lists of unknown and missing files in the working copy.
       
   220 
       
   221     Args:
       
   222       path: The working copy path to scan.
    95 
   223 
    96     Returns:
   224     Returns:
    97       True if the user answered affirmatively, False otherwise.
   225 
    98     """
   226       Two lists. The first is a list of all unknown paths
    99     if default:
   227       (subversion has no knowledge of them), the second is a list
   100         question = prompt + ' [Yn] '
   228       of missing paths (subversion knows about them, but can't
       
   229       find them). Paths in either list are relative to the input
       
   230       path.
       
   231     """
       
   232     assert self.exists()
       
   233     unknown = []
       
   234     missing = []
       
   235     for line in self.status(path):
       
   236       if not line.strip():
       
   237         continue
       
   238       if line[0] == '?':
       
   239         unknown.append(line[7:])
       
   240       elif line[0] == '!':
       
   241         missing.append(line[7:])
       
   242     return unknown, missing
       
   243 
       
   244   def checkout(self, url, depth='infinity'):
       
   245     """Check out a working copy from the given URL.
       
   246 
       
   247     Args:
       
   248       url: The Subversion repository URL to check out.
       
   249       depth: The depth of the working copy root.
       
   250     """
       
   251     assert not self.exists()
       
   252     util.run(['svn', 'checkout', '--depth=' + depth, url, self.path()])
       
   253 
       
   254   def update(self, path='', depth=None):
       
   255     """Update a working copy path, optionally changing depth.
       
   256 
       
   257     Args:
       
   258       path: The working copy path to update.
       
   259       depth: If set, change the depth of the path before updating.
       
   260     """
       
   261     assert self.exists()
       
   262     if depth is None:
       
   263       util.run(['svn', 'update', self.path(path)])
   101     else:
   264     else:
   102         question = prompt + ' [yN] '
   265       util.run(['svn', 'update', '--set-depth=' + depth, self.path(path)])
   103     while True:
   266 
   104         try:
   267   def revert(self, path=''):
   105             answer = raw_input(question).strip().lower()
   268     """Recursively revert a working copy path.
   106         except EOFError:
   269 
   107             raise AbortedByUser('Aborted by ctrl+D')
   270     Note that this command is more zealous than the 'svn revert'
   108         if not answer:
   271     command, as it will also delete any files which subversion
   109             return default
   272     does not know about.
   110         elif answer in ('y', 'yes'):
   273     """
   111             return True
   274     util.run(['svn', 'revert', '-R', self.path(path)])
   112         elif answer in ('n', 'no'):
   275 
   113             return False
   276     unknown, missing = self._unknownAndMissing(path)
   114         else:
   277     unknown = [os.path.join(self.path(path), p) for p in unknown]
   115             log.error('Please answer yes or no.')
   278 
   116 
   279     if unknown:
   117 
   280       # rm -rf makes me uneasy. Verify that all paths to be deleted
   118 def getString(prompt):
   281       # are within the release working copy.
   119     """Prompt for and return a string."""
   282       for p in unknown:
       
   283         assert p.startswith(self.path())
       
   284 
       
   285       util.run(['rm', '-rf', '--'] + unknown)
       
   286 
       
   287   def ls(self, dir=''):
       
   288     """List the contents of a working copy directory.
       
   289 
       
   290     Note that this returns the contents of the directory as seen
       
   291     by the server, not constrained by the depth settings of the
       
   292     local path.
       
   293     """
       
   294     assert self.exists()
       
   295     return util.run(['svn', 'ls', self.path(dir)], capture=True)
       
   296 
       
   297   def copy(self, src, dest):
       
   298     """Copy a working copy path.
       
   299 
       
   300     The copy is only scheduled for commit, not committed.
       
   301 
       
   302     Args:
       
   303       src: The source working copy path.
       
   304       dst: The destination working copy path.
       
   305     """
       
   306     assert self.exists()
       
   307     util.run(['svn', 'cp', self.path(src), self.path(dest)])
       
   308 
       
   309   def propget(self, prop_name, path):
       
   310     """Get the value of a property on a working copy path.
       
   311 
       
   312     Args:
       
   313       prop_name: The property name, eg. 'svn:externals'.
       
   314       path: The working copy path on which the property is set.
       
   315     """
       
   316     assert self.exists()
       
   317     return util.run(['svn', 'propget', prop_name, self.path(path)],
       
   318             capture=True)
       
   319 
       
   320   def propset(self, prop_name, prop_value, path):
       
   321     """Set the value of a property on a working copy path.
       
   322 
       
   323     The property change is only scheduled for commit, not committed.
       
   324 
       
   325     Args:
       
   326       prop_name: The property name, eg. 'svn:externals'.
       
   327       prop_value: The value that should be set.
       
   328       path: The working copy path on which to set the property.
       
   329     """
       
   330     assert self.exists()
       
   331     util.run(['svn', 'propset', prop_name, prop_value, self.path(path)])
       
   332 
       
   333   def add(self, paths):
       
   334     """Schedule working copy paths for addition.
       
   335 
       
   336     The paths are only scheduled for addition, not committed.
       
   337 
       
   338     Args:
       
   339       paths: The list of working copy paths to add.
       
   340     """
       
   341     assert self.exists()
       
   342     paths = [self.path(p) for p in paths]
       
   343     util.run(['svn', 'add'] + paths)
       
   344 
       
   345   def remove(self, paths):
       
   346     """Schedule working copy paths for deletion.
       
   347 
       
   348     The paths are only scheduled for deletion, not committed.
       
   349 
       
   350     Args:
       
   351       paths: The list of working copy paths to delete.
       
   352     """
       
   353     assert self.exists()
       
   354     paths = [self.path(p) for p in paths]
       
   355     util.run(['svn', 'rm'] + paths)
       
   356 
       
   357   def status(self, path=''):
       
   358     """Return the status of a working copy path.
       
   359 
       
   360     The status returned is the verbatim output of 'svn status' on
       
   361     the path.
       
   362 
       
   363     Args:
       
   364       path: The path to examine.
       
   365     """
       
   366     assert self.exists()
       
   367     return util.run(['svn', 'status', self.path(path)], capture=True)
       
   368 
       
   369   def addRemove(self, path=''):
       
   370     """Perform an "addremove" operation a working copy path.
       
   371 
       
   372     An "addremove" runs 'svn status' and schedules all the unknown
       
   373     paths (listed as '?') for addition, and all the missing paths
       
   374     (listed as '!') for deletion. Its main use is to synchronize
       
   375     working copy state after applying a patch in unified diff
       
   376     format.
       
   377 
       
   378     Args:
       
   379       path: The path under which unknown/missing files should be
       
   380         added/removed.
       
   381     """
       
   382     assert self.exists()
       
   383     unknown, missing = self._unknownAndMissing(path)
       
   384     if unknown:
       
   385       self.add(unknown)
       
   386     if missing:
       
   387       self.remove(missing)
       
   388 
       
   389   def commit(self, message, path=''):
       
   390     """Commit scheduled changes to the source repository.
       
   391 
       
   392     Args:
       
   393       message: The commit message to use.
       
   394       path: The path to commit.
       
   395     """
       
   396     assert self.exists()
       
   397     util.run(['svn', 'commit', '-m', message, self.path(path)])
       
   398 
       
   399   @staticmethod
       
   400   def export(url, revision, dest_path):
       
   401     """Export the contents of a repository to a local path.
       
   402 
       
   403     Note that while the underlying 'svn export' only requires a
       
   404     URL, we require that both a URL and a revision be specified,
       
   405     to fully qualify the data to export.
       
   406 
       
   407     Args:
       
   408       url: The repository URL to export.
       
   409       revision: The revision to export.
       
   410       dest_path: The destination directory for the export. Note
       
   411            that this is an absolute path, NOT a working copy
       
   412            relative path.
       
   413     """
       
   414     assert os.path.isabs(dest_path)
       
   415     if os.path.exists(dest_path):
       
   416       raise ObstructionError('Cannot export to obstructed path %s' %
       
   417                    dest_path)
       
   418     util.run(['svn', 'export', '-r', str(revision), url, dest_path])
       
   419 
       
   420   @staticmethod
       
   421   def find_tag_rev(url):
       
   422     """Return the revision at which a remote tag was created.
       
   423 
       
   424     Since tags are immutable by convention, usually the HEAD of a
       
   425     tag should be the tag creation revision. However, mistakes can
       
   426     happen, so this function will walk the history of the given
       
   427     tag URL, stopping on the first revision that was created by
       
   428     copy.
       
   429 
       
   430     This detection is not foolproof. For example: it will be
       
   431     fooled by a tag that was created, deleted, and recreated by
       
   432     copy at a different revision. It is not clear what the desired
       
   433     behavior in these edge cases are, and no attempt is made to
       
   434     handle them. You should request user confirmation before using
       
   435     the result of this function.
       
   436 
       
   437     Args:
       
   438       url: The repository URL of the tag to examine.
       
   439     """
   120     try:
   440     try:
   121         return raw_input(prompt + ' ').strip()
   441       output = util.run(['svn', 'log', '-q', '--stop-on-copy', url],
   122     except EOFError:
   442                 capture=True)
   123         raise AbortedByUser('Aborted by ctrl+D')
   443     except util.SubprocessFailed:
   124 
   444       raise ExpectationFailed('No tag at URL ' + url)
   125 
   445     first_rev_line = output[-2]
   126 def getNumber(prompt):
   446     first_rev = int(first_rev_line.split()[0][1:])
   127     """Prompt for and return a number.
   447     return first_rev
   128 
   448 
   129     Will reprompt the user until a number is entered.
   449   @staticmethod
   130     """
   450   def diff(url, revision):
   131     while True:
   451     """Retrieve a revision from a remote repository as a unified diff.
   132         value_str = getString(prompt)
   452 
   133         try:
   453     Args:
   134             return int(value_str)
   454       url: The repository URL on which to perform the diff.
   135         except ValueError:
   455       revision: The revision to extract at the given url.
   136             log.error('Please enter a number. You entered "%s".' % value_str)
       
   137 
       
   138 
       
   139 def getChoice(intro, prompt, choices, done=None, suggest=None):
       
   140     """Prompt for and return a choice from a menu.
       
   141 
       
   142     Will reprompt the user until a valid menu entry is chosen.
       
   143 
       
   144     Args:
       
   145       intro: Text to print verbatim before the choice menu.
       
   146       prompt: The prompt to print right before accepting input.
       
   147       choices: The list of string choices to display.
       
   148       done: If not None, the list of indices of previously
       
   149             selected/completed choices.
       
   150       suggest: If not None, the index of the choice to highlight as
       
   151                the suggested choice.
       
   152 
   456 
   153     Returns:
   457     Returns:
   154       The index in the choices list of the selection the user made.
   458       A string containing the changes extracted from the remote
   155     """
   459       repository, in unified diff format suitable for application
   156     done = set(done or [])
   460       using 'patch'.
   157     while True:
   461     """
   158         print intro
       
   159         print
       
   160         for i, entry in enumerate(choices):
       
   161             done_text = ' (done)' if i in done else ''
       
   162             indent = '--> ' if i == suggest else '    '
       
   163             print '%s%2d. %s%s' % (indent, i+1, entry, done_text)
       
   164         print
       
   165         choice = getNumber(prompt)
       
   166         if 0 < choice <= len(choices):
       
   167             return choice-1
       
   168         log.error('%d is not a valid choice between %d and %d' %
       
   169                   (choice, 1, len(choices)))
       
   170         print
       
   171 
       
   172 
       
   173 def fileToLines(path):
       
   174     """Read a file and return it as a list of lines."""
       
   175     try:
   462     try:
   176         with file(path) as f:
   463       return util.run(['svn', 'diff', '-c', str(revision), url],
   177             return f.read().split('\n')
   464               capture=True, split_capture=False)
   178     except (IOError, OSError), e:
   465     except util.SubprocessFailed:
   179         raise FileAccessError(str(e))
   466       raise ExpectationFailed('Could not get diff for r%d '
   180 
   467                   'from remote repository' % revision)
   181 
       
   182 def linesToFile(path, lines):
       
   183     """Write a list of lines to a file."""
       
   184     try:
       
   185         with file(path, 'w') as f:
       
   186             f.write('\n'.join(lines))
       
   187     except (IOError, OSError), e:
       
   188         raise FileAccessError(str(e))
       
   189 
       
   190 
       
   191 class Subversion(util.Paths):
       
   192     """Wrapper for operations on a Subversion working copy.
       
   193 
       
   194     An instance of this class is bound to a specific working copy
       
   195     directory, and provides an API to perform various Subversion
       
   196     operations on this working copy.
       
   197 
       
   198     Some methods take a 'depth' argument. Depth in Subversion is a
       
   199     feature that allows the creation of arbitrarily shallow or deep
       
   200     working copies on a per-directory basis. Possible values are
       
   201     'none' (no files or directories), 'files' (only files in .),
       
   202     'immediates' (files and directories in ., directories checked out
       
   203     at depth 'none') or 'infinity' (a normal working copy with
       
   204     everything).
       
   205 
       
   206     This class also provides a few static functions that run the 'svn'
       
   207     tool against remote repositories to gather information or retrieve
       
   208     data.
       
   209 
       
   210     Note that this wrapper also doubles as a Paths object, offering an
       
   211     easy way to get or check the existence of paths in the working
       
   212     copy.
       
   213     """
       
   214 
       
   215     def __init__(self, wc_dir):
       
   216         util.Paths.__init__(self, wc_dir)
       
   217 
       
   218     def _unknownAndMissing(self, path):
       
   219         """Returns lists of unknown and missing files in the working copy.
       
   220 
       
   221         Args:
       
   222           path: The working copy path to scan.
       
   223 
       
   224         Returns:
       
   225 
       
   226           Two lists. The first is a list of all unknown paths
       
   227           (subversion has no knowledge of them), the second is a list
       
   228           of missing paths (subversion knows about them, but can't
       
   229           find them). Paths in either list are relative to the input
       
   230           path.
       
   231         """
       
   232         assert self.exists()
       
   233         unknown = []
       
   234         missing = []
       
   235         for line in self.status(path):
       
   236             if not line.strip():
       
   237                 continue
       
   238             if line[0] == '?':
       
   239                 unknown.append(line[7:])
       
   240             elif line[0] == '!':
       
   241                 missing.append(line[7:])
       
   242         return unknown, missing
       
   243 
       
   244     def checkout(self, url, depth='infinity'):
       
   245         """Check out a working copy from the given URL.
       
   246 
       
   247         Args:
       
   248           url: The Subversion repository URL to check out.
       
   249           depth: The depth of the working copy root.
       
   250         """
       
   251         assert not self.exists()
       
   252         util.run(['svn', 'checkout', '--depth=' + depth, url, self.path()])
       
   253 
       
   254     def update(self, path='', depth=None):
       
   255         """Update a working copy path, optionally changing depth.
       
   256 
       
   257         Args:
       
   258           path: The working copy path to update.
       
   259           depth: If set, change the depth of the path before updating.
       
   260         """
       
   261         assert self.exists()
       
   262         if depth is None:
       
   263             util.run(['svn', 'update', self.path(path)])
       
   264         else:
       
   265             util.run(['svn', 'update', '--set-depth=' + depth, self.path(path)])
       
   266 
       
   267     def revert(self, path=''):
       
   268         """Recursively revert a working copy path.
       
   269 
       
   270         Note that this command is more zealous than the 'svn revert'
       
   271         command, as it will also delete any files which subversion
       
   272         does not know about.
       
   273         """
       
   274         util.run(['svn', 'revert', '-R', self.path(path)])
       
   275 
       
   276         unknown, missing = self._unknownAndMissing(path)
       
   277         unknown = [os.path.join(self.path(path), p) for p in unknown]
       
   278 
       
   279         if unknown:
       
   280             # rm -rf makes me uneasy. Verify that all paths to be deleted
       
   281             # are within the release working copy.
       
   282             for p in unknown:
       
   283                 assert p.startswith(self.path())
       
   284 
       
   285             util.run(['rm', '-rf', '--'] + unknown)
       
   286 
       
   287     def ls(self, dir=''):
       
   288         """List the contents of a working copy directory.
       
   289 
       
   290         Note that this returns the contents of the directory as seen
       
   291         by the server, not constrained by the depth settings of the
       
   292         local path.
       
   293         """
       
   294         assert self.exists()
       
   295         return util.run(['svn', 'ls', self.path(dir)], capture=True)
       
   296 
       
   297     def copy(self, src, dest):
       
   298         """Copy a working copy path.
       
   299 
       
   300         The copy is only scheduled for commit, not committed.
       
   301 
       
   302         Args:
       
   303           src: The source working copy path.
       
   304           dst: The destination working copy path.
       
   305         """
       
   306         assert self.exists()
       
   307         util.run(['svn', 'cp', self.path(src), self.path(dest)])
       
   308 
       
   309     def propget(self, prop_name, path):
       
   310         """Get the value of a property on a working copy path.
       
   311 
       
   312         Args:
       
   313           prop_name: The property name, eg. 'svn:externals'.
       
   314           path: The working copy path on which the property is set.
       
   315         """
       
   316         assert self.exists()
       
   317         return util.run(['svn', 'propget', prop_name, self.path(path)],
       
   318                         capture=True)
       
   319 
       
   320     def propset(self, prop_name, prop_value, path):
       
   321         """Set the value of a property on a working copy path.
       
   322 
       
   323         The property change is only scheduled for commit, not committed.
       
   324 
       
   325         Args:
       
   326           prop_name: The property name, eg. 'svn:externals'.
       
   327           prop_value: The value that should be set.
       
   328           path: The working copy path on which to set the property.
       
   329         """
       
   330         assert self.exists()
       
   331         util.run(['svn', 'propset', prop_name, prop_value, self.path(path)])
       
   332 
       
   333     def add(self, paths):
       
   334         """Schedule working copy paths for addition.
       
   335 
       
   336         The paths are only scheduled for addition, not committed.
       
   337 
       
   338         Args:
       
   339           paths: The list of working copy paths to add.
       
   340         """
       
   341         assert self.exists()
       
   342         paths = [self.path(p) for p in paths]
       
   343         util.run(['svn', 'add'] + paths)
       
   344 
       
   345     def remove(self, paths):
       
   346         """Schedule working copy paths for deletion.
       
   347 
       
   348         The paths are only scheduled for deletion, not committed.
       
   349 
       
   350         Args:
       
   351           paths: The list of working copy paths to delete.
       
   352         """
       
   353         assert self.exists()
       
   354         paths = [self.path(p) for p in paths]
       
   355         util.run(['svn', 'rm'] + paths)
       
   356 
       
   357     def status(self, path=''):
       
   358         """Return the status of a working copy path.
       
   359 
       
   360         The status returned is the verbatim output of 'svn status' on
       
   361         the path.
       
   362 
       
   363         Args:
       
   364           path: The path to examine.
       
   365         """
       
   366         assert self.exists()
       
   367         return util.run(['svn', 'status', self.path(path)], capture=True)
       
   368 
       
   369     def addRemove(self, path=''):
       
   370         """Perform an "addremove" operation a working copy path.
       
   371 
       
   372         An "addremove" runs 'svn status' and schedules all the unknown
       
   373         paths (listed as '?') for addition, and all the missing paths
       
   374         (listed as '!') for deletion. Its main use is to synchronize
       
   375         working copy state after applying a patch in unified diff
       
   376         format.
       
   377 
       
   378         Args:
       
   379           path: The path under which unknown/missing files should be
       
   380                 added/removed.
       
   381         """
       
   382         assert self.exists()
       
   383         unknown, missing = self._unknownAndMissing(path)
       
   384         if unknown:
       
   385             self.add(unknown)
       
   386         if missing:
       
   387             self.remove(missing)
       
   388 
       
   389     def commit(self, message, path=''):
       
   390         """Commit scheduled changes to the source repository.
       
   391 
       
   392         Args:
       
   393           message: The commit message to use.
       
   394           path: The path to commit.
       
   395         """
       
   396         assert self.exists()
       
   397         util.run(['svn', 'commit', '-m', message, self.path(path)])
       
   398 
       
   399     @staticmethod
       
   400     def export(url, revision, dest_path):
       
   401         """Export the contents of a repository to a local path.
       
   402 
       
   403         Note that while the underlying 'svn export' only requires a
       
   404         URL, we require that both a URL and a revision be specified,
       
   405         to fully qualify the data to export.
       
   406 
       
   407         Args:
       
   408           url: The repository URL to export.
       
   409           revision: The revision to export.
       
   410           dest_path: The destination directory for the export. Note
       
   411                      that this is an absolute path, NOT a working copy
       
   412                      relative path.
       
   413         """
       
   414         assert os.path.isabs(dest_path)
       
   415         if os.path.exists(dest_path):
       
   416             raise ObstructionError('Cannot export to obstructed path %s' %
       
   417                                    dest_path)
       
   418         util.run(['svn', 'export', '-r', str(revision), url, dest_path])
       
   419 
       
   420     @staticmethod
       
   421     def find_tag_rev(url):
       
   422         """Return the revision at which a remote tag was created.
       
   423 
       
   424         Since tags are immutable by convention, usually the HEAD of a
       
   425         tag should be the tag creation revision. However, mistakes can
       
   426         happen, so this function will walk the history of the given
       
   427         tag URL, stopping on the first revision that was created by
       
   428         copy.
       
   429 
       
   430         This detection is not foolproof. For example: it will be
       
   431         fooled by a tag that was created, deleted, and recreated by
       
   432         copy at a different revision. It is not clear what the desired
       
   433         behavior in these edge cases are, and no attempt is made to
       
   434         handle them. You should request user confirmation before using
       
   435         the result of this function.
       
   436 
       
   437         Args:
       
   438           url: The repository URL of the tag to examine.
       
   439         """
       
   440         try:
       
   441             output = util.run(['svn', 'log', '-q', '--stop-on-copy', url],
       
   442                               capture=True)
       
   443         except util.SubprocessFailed:
       
   444             raise ExpectationFailed('No tag at URL ' + url)
       
   445         first_rev_line = output[-2]
       
   446         first_rev = int(first_rev_line.split()[0][1:])
       
   447         return first_rev
       
   448 
       
   449     @staticmethod
       
   450     def diff(url, revision):
       
   451         """Retrieve a revision from a remote repository as a unified diff.
       
   452 
       
   453         Args:
       
   454           url: The repository URL on which to perform the diff.
       
   455           revision: The revision to extract at the given url.
       
   456 
       
   457         Returns:
       
   458           A string containing the changes extracted from the remote
       
   459           repository, in unified diff format suitable for application
       
   460           using 'patch'.
       
   461         """
       
   462         try:
       
   463             return util.run(['svn', 'diff', '-c', str(revision), url],
       
   464                             capture=True, split_capture=False)
       
   465         except util.SubprocessFailed:
       
   466             raise ExpectationFailed('Could not get diff for r%d '
       
   467                                     'from remote repository' % revision)
       
   468 
   468 
   469 
   469 
   470 #
   470 #
   471 # Decorators for use in ReleaseEnvironment.
   471 # Decorators for use in ReleaseEnvironment.
   472 #
   472 #
   473 def pristine_wc(f):
   473 def pristine_wc(f):
   474     """A decorator that cleans up the release repository."""
   474   """A decorator that cleans up the release repository."""
   475     @functools.wraps(f)
   475   @functools.wraps(f)
   476     def revert_wc(self, *args, **kwargs):
   476   def revert_wc(self, *args, **kwargs):
   477         self.wc.revert()
   477     self.wc.revert()
   478         return f(self, *args, **kwargs)
   478     return f(self, *args, **kwargs)
   479     return revert_wc
   479   return revert_wc
   480 
   480 
   481 
   481 
   482 def requires_branch(f):
   482 def requires_branch(f):
   483     """A decorator that checks that a release branch is active."""
   483   """A decorator that checks that a release branch is active."""
   484     @functools.wraps(f)
   484   @functools.wraps(f)
   485     def check_branch(self, *args, **kwargs):
   485   def check_branch(self, *args, **kwargs):
   486         if self.branch is None:
   486     if self.branch is None:
   487             raise ExpectationFailed(
   487       raise ExpectationFailed(
   488                 'This operation requires an active release branch')
   488         'This operation requires an active release branch')
   489         return f(self, *args, **kwargs)
   489     return f(self, *args, **kwargs)
   490     return check_branch
   490   return check_branch
   491 
   491 
   492 
   492 
   493 class ReleaseEnvironment(util.Paths):
   493 class ReleaseEnvironment(util.Paths):
   494     """Encapsulates the state of a Melange release rolling environment.
   494   """Encapsulates the state of a Melange release rolling environment.
   495 
   495 
   496     This class contains the actual releasing logic, and makes use of
   496   This class contains the actual releasing logic, and makes use of
   497     the previously defined utility classes to carry out user commands.
   497   the previously defined utility classes to carry out user commands.
   498 
   498 
   499     Attributes:
   499   Attributes:
       
   500     release_repos: The URL to the Google release repository root.
       
   501     upstream_repos: The URL to the Melange upstream repository root.
       
   502     wc: A Subversion object encapsulating a Google SoC working copy.
       
   503   """
       
   504 
       
   505   BRANCH_FILE = 'BRANCH'
       
   506 
       
   507   def __init__(self, root, release_repos, upstream_repos):
       
   508     """Initializer.
       
   509 
       
   510     Args:
       
   511       root: The root of the release environment.
   500       release_repos: The URL to the Google release repository root.
   512       release_repos: The URL to the Google release repository root.
   501       upstream_repos: The URL to the Melange upstream repository root.
   513       upstream_repos: The URL to the Melange upstream repository root.
   502       wc: A Subversion object encapsulating a Google SoC working copy.
   514     """
   503     """
   515     util.Paths.__init__(self, root)
   504 
   516     self.wc = Subversion(self.path('google-soc'))
   505     BRANCH_FILE = 'BRANCH'
   517     self.release_repos = release_repos.strip('/')
   506 
   518     self.upstream_repos = upstream_repos.strip('/')
   507     def __init__(self, root, release_repos, upstream_repos):
   519 
   508         """Initializer.
   520     if not self.wc.exists():
   509 
   521       self._InitializeWC()
   510         Args:
   522     else:
   511           root: The root of the release environment.
   523       self.wc.revert()
   512           release_repos: The URL to the Google release repository root.
   524 
   513           upstream_repos: The URL to the Melange upstream repository root.
   525       if self.exists(self.BRANCH_FILE):
   514         """
   526         branch = fileToLines(self.path(self.BRANCH_FILE))[0]
   515         util.Paths.__init__(self, root)
   527         self._switchBranch(branch)
   516         self.wc = Subversion(self.path('google-soc'))
   528       else:
   517         self.release_repos = release_repos.strip('/')
   529         self._switchBranch(None)
   518         self.upstream_repos = upstream_repos.strip('/')
   530 
   519 
   531   def _InitializeWC(self):
   520         if not self.wc.exists():
   532     """Check out the initial release repository.
   521             self._InitializeWC()
   533 
   522         else:
   534     Will also select the latest release branch, if any, so that
   523             self.wc.revert()
   535     the end state is a fully ready to function release
   524 
   536     environment.
   525             if self.exists(self.BRANCH_FILE):
   537     """
   526                 branch = fileToLines(self.path(self.BRANCH_FILE))[0]
   538     log.info('Checking out the release repository')
   527                 self._switchBranch(branch)
   539 
   528             else:
   540     # Check out a sparse view of the relevant repository paths.
   529                 self._switchBranch(None)
   541     self.wc.checkout(self.release_repos, depth='immediates')
   530 
   542     self.wc.update('vendor', depth='immediates')
   531     def _InitializeWC(self):
   543     self.wc.update('branches', depth='immediates')
   532         """Check out the initial release repository.
   544     self.wc.update('tags', depth='immediates')
   533 
   545 
   534         Will also select the latest release branch, if any, so that
   546     # Locate the most recent release branch, if any, and switch
   535         the end state is a fully ready to function release
   547     # the release environment to it.
   536         environment.
   548     branches = self._listBranches()
   537         """
   549     if not branches:
   538         log.info('Checking out the release repository')
   550       self._switchBranch(None)
   539 
   551     else:
   540         # Check out a sparse view of the relevant repository paths.
   552       self._switchBranch(branches[-1])
   541         self.wc.checkout(self.release_repos, depth='immediates')
   553 
   542         self.wc.update('vendor', depth='immediates')
   554   def _listBranches(self):
   543         self.wc.update('branches', depth='immediates')
   555     """Return a list of available Melange release branches.
   544         self.wc.update('tags', depth='immediates')
   556 
   545 
   557     Branches are returned in sorted order, from least recent to
   546         # Locate the most recent release branch, if any, and switch
   558     most recent in release number ordering.
   547         # the release environment to it.
   559     """
   548         branches = self._listBranches()
   560     assert self.wc.exists('branches')
   549         if not branches:
   561     branches = self.wc.ls('branches')
   550             self._switchBranch(None)
   562 
   551         else:
   563     # Some early release branches used a different naming scheme
   552             self._switchBranch(branches[-1])
   564     # that doesn't sort properly with new-style release names. We
   553 
   565     # filter those out here, along with empty lines.
   554     def _listBranches(self):
   566     branches = [b.strip('/') for b in branches
   555         """Return a list of available Melange release branches.
   567           if MELANGE_RELEASE_RE.match(b.strip('/'))]
   556 
   568 
   557         Branches are returned in sorted order, from least recent to
   569     return sorted(branches)
   558         most recent in release number ordering.
   570 
   559         """
   571   def _switchBranch(self, release):
   560         assert self.wc.exists('branches')
   572     """Activate the branch matching the given release.
   561         branches = self.wc.ls('branches')
   573 
   562 
   574     Once activated, this branch is the target of future release
   563         # Some early release branches used a different naming scheme
   575     operations.
   564         # that doesn't sort properly with new-style release names. We
   576 
   565         # filter those out here, along with empty lines.
   577     None can be passed as the release. The result is that no
   566         branches = [b.strip('/') for b in branches
   578     branch is active, and all operations that require an active
   567                     if MELANGE_RELEASE_RE.match(b.strip('/'))]
   579     branch will fail until a branch is activated again. This is
   568 
   580     used only at initialization, when it is detected that there
   569         return sorted(branches)
   581     are no available release branches to activate.
   570 
   582 
   571     def _switchBranch(self, release):
   583     Args:
   572         """Activate the branch matching the given release.
   584       release: The version number of a Melange release already
   573 
   585            imported in the release repository, or None to
   574         Once activated, this branch is the target of future release
   586            activate no branch.
   575         operations.
   587 
   576 
   588     """
   577         None can be passed as the release. The result is that no
   589     if release is None:
   578         branch is active, and all operations that require an active
   590       self.branch = None
   579         branch will fail until a branch is activated again. This is
   591       self.branch_dir = None
   580         used only at initialization, when it is detected that there
   592       log.info('No release branch available')
   581         are no available release branches to activate.
   593     else:
   582 
   594       self.wc.update()
   583         Args:
   595       assert self.wc.exists('branches/' + release)
   584           release: The version number of a Melange release already
   596       linesToFile(self.path(self.BRANCH_FILE), [release])
   585                    imported in the release repository, or None to
   597       self.branch = release
   586                    activate no branch.
   598       self.branch_dir = 'branches/' + release
   587 
   599       self.wc.update(self.branch_dir, depth='infinity')
   588         """
   600       log.info('Working on branch ' + self.branch)
   589         if release is None:
   601 
   590             self.branch = None
   602   def _branchPath(self, path):
   591             self.branch_dir = None
   603     """Return the given path with the release branch path prepended."""
   592             log.info('No release branch available')
   604     assert self.branch_dir is not None
   593         else:
   605     return os.path.join(self.branch_dir, path)
   594             self.wc.update()
   606 
   595             assert self.wc.exists('branches/' + release)
   607   #
   596             linesToFile(self.path(self.BRANCH_FILE), [release])
   608   # Release engineering commands. See further down for their
   597             self.branch = release
   609   # integration into a commandline interface.
   598             self.branch_dir = 'branches/' + release
   610   #
   599             self.wc.update(self.branch_dir, depth='infinity')
   611   @pristine_wc
   600             log.info('Working on branch ' + self.branch)
   612   def update(self):
   601 
   613     """Update and clean the release repository"""
   602     def _branchPath(self, path):
   614     self.wc.update()
   603         """Return the given path with the release branch path prepended."""
   615 
   604         assert self.branch_dir is not None
   616   @pristine_wc
   605         return os.path.join(self.branch_dir, path)
   617   def switchToBranch(self):
   606 
   618     """Switch to another Melange release branch"""
   607     #
   619     branches = self._listBranches()
   608     # Release engineering commands. See further down for their
   620     if not branches:
   609     # integration into a commandline interface.
   621       raise ExpectationFailed(
   610     #
   622         'No branches available. Please import one.')
   611     @pristine_wc
   623 
   612     def update(self):
   624     choice = getChoice('Available release branches:',
   613         """Update and clean the release repository"""
   625                'Your choice?',
   614         self.wc.update()
   626                branches,
   615 
   627                suggest=len(branches)-1)
   616     @pristine_wc
   628     self._switchBranch(branches[choice])
   617     def switchToBranch(self):
   629 
   618         """Switch to another Melange release branch"""
   630   def _addAppYaml(self):
   619         branches = self._listBranches()
   631     """Create a Google production app.yaml configuration.
   620         if not branches:
   632 
   621             raise ExpectationFailed(
   633     The file is copied and modified from the upstream
   622                 'No branches available. Please import one.')
   634     app.yaml.template, configure for Google's Summer of Code App
   623 
   635     Engine instance, and committed.
   624         choice = getChoice('Available release branches:',
   636     """
   625                            'Your choice?',
   637     if self.wc.exists(self._branchPath('app/app.yaml')):
   626                            branches,
   638       raise ObstructionError('app/app.yaml exists already')
   627                            suggest=len(branches)-1)
   639 
   628         self._switchBranch(branches[choice])
   640     yaml_path = self._branchPath('app/app.yaml')
   629 
   641     self.wc.copy(yaml_path + '.template', yaml_path)
   630     def _addAppYaml(self):
   642 
   631         """Create a Google production app.yaml configuration.
   643     yaml = fileToLines(self.wc.path(yaml_path))
   632 
   644     out = []
   633         The file is copied and modified from the upstream
   645     for i, line in enumerate(yaml):
   634         app.yaml.template, configure for Google's Summer of Code App
   646       stripped_line = line.strip()
   635         Engine instance, and committed.
   647       if 'TODO' in stripped_line:
   636         """
   648         continue
   637         if self.wc.exists(self._branchPath('app/app.yaml')):
   649       elif stripped_line == '# application: FIXME':
   638             raise ObstructionError('app/app.yaml exists already')
   650         out.append('application: socghop')
   639 
   651       elif stripped_line.startswith('version:'):
   640         yaml_path = self._branchPath('app/app.yaml')
   652         out.append(line.lstrip() + 'g0')
   641         self.wc.copy(yaml_path + '.template', yaml_path)
   653         out.append('# * initial Google fork of Melange ' +
   642 
   654                self.branch)
   643         yaml = fileToLines(self.wc.path(yaml_path))
   655       else:
   644         out = []
   656         out.append(line)
   645         for i, line in enumerate(yaml):
   657     linesToFile(self.wc.path(yaml_path), out)
   646             stripped_line = line.strip()
   658 
   647             if 'TODO' in stripped_line:
   659     self.wc.commit('Create app.yaml with Google patch version g0 '
   648                 continue
   660              'in branch ' + self.branch)
   649             elif stripped_line == '# application: FIXME':
   661 
   650                 out.append('application: socghop')
   662   def _applyGooglePatches(self):
   651             elif stripped_line.startswith('version:'):
   663     """Apply Google-specific patches to a vanilla Melange release.
   652                 out.append(line.lstrip() + 'g0')
   664 
   653                 out.append('# * initial Google fork of Melange ' +
   665     Each patch is applied and committed in turn.
   654                            self.branch)
   666     """
   655             else:
   667     # Edit the base template to point users to the Google fork
   656                 out.append(line)
   668     # of the Melange codebase instead of the vanilla release.
   657         linesToFile(self.wc.path(yaml_path), out)
   669     tmpl_file = self.wc.path(
   658 
   670       self._branchPath('app/soc/templates/soc/base.html'))
   659         self.wc.commit('Create app.yaml with Google patch version g0 '
   671     tmpl = fileToLines(tmpl_file)
   660                        'in branch ' + self.branch)
   672     for i, line in enumerate(tmpl):
   661 
   673       if 'http://code.google.com/p/soc/source/browse/tags/' in line:
   662     def _applyGooglePatches(self):
   674         tmpl[i] = line.replace('/p/soc/', '/p/soc-google/')
   663         """Apply Google-specific patches to a vanilla Melange release.
   675         break
   664 
   676     else:
   665         Each patch is applied and committed in turn.
   677       raise ExpectationFailed(
   666         """
   678         'No source code link found in base.html')
   667         # Edit the base template to point users to the Google fork
   679     linesToFile(tmpl_file, tmpl)
   668         # of the Melange codebase instead of the vanilla release.
   680 
   669         tmpl_file = self.wc.path(
   681     self.wc.commit(
   670             self._branchPath('app/soc/templates/soc/base.html'))
   682       'Customize the Melange release link in the sidebar menu')
   671         tmpl = fileToLines(tmpl_file)
   683 
   672         for i, line in enumerate(tmpl):
   684   @pristine_wc
   673             if 'http://code.google.com/p/soc/source/browse/tags/' in line:
   685   def importTag(self):
   674                 tmpl[i] = line.replace('/p/soc/', '/p/soc-google/')
   686     """Import a new Melange release"""
   675                 break
   687     release = getString('Enter the Melange release to import:')
   676         else:
   688     if not release:
   677             raise ExpectationFailed(
   689       AbortedByUser('No release provided, import aborted')
   678                 'No source code link found in base.html')
   690 
   679         linesToFile(tmpl_file, tmpl)
   691     branch_dir = 'branches/' + release
   680 
   692     if self.wc.exists(branch_dir):
   681         self.wc.commit(
   693       raise ObstructionError('Release %s already imported' % release)
   682             'Customize the Melange release link in the sidebar menu')
   694 
   683 
   695     tag_url = '%s/tags/%s' % (self.upstream_repos, release)
   684     @pristine_wc
   696     release_rev = Subversion.find_tag_rev(tag_url)
   685     def importTag(self):
   697 
   686         """Import a new Melange release"""
   698     if confirm('Confirm import of release %s, tagged at r%d?' %
   687         release = getString('Enter the Melange release to import:')
   699            (release, release_rev)):
   688         if not release:
   700       # Add an entry to the vendor externals for the Melange
   689             AbortedByUser('No release provided, import aborted')
   701       # release.
   690 
   702       externals = self.wc.propget('svn:externals', 'vendor/soc')
   691         branch_dir = 'branches/' + release
   703       externals.append('%s -r %d %s' % (release, release_rev, tag_url))
   692         if self.wc.exists(branch_dir):
   704       self.wc.propset('svn:externals', '\n'.join(externals), 'vendor/soc')
   693             raise ObstructionError('Release %s already imported' % release)
   705       self.wc.commit('Add svn:externals entry to pull in Melange '
   694 
   706                'release %s at r%d.' % (release, release_rev))
   695         tag_url = '%s/tags/%s' % (self.upstream_repos, release)
   707 
   696         release_rev = Subversion.find_tag_rev(tag_url)
   708       # Export the tag into the release repository's branches
   697 
   709       Subversion.export(tag_url, release_rev, self.wc.path(branch_dir))
   698         if confirm('Confirm import of release %s, tagged at r%d?' %
   710 
   699                    (release, release_rev)):
   711       # Add and commit the branch add (very long operation!)
   700             # Add an entry to the vendor externals for the Melange
   712       self.wc.add([branch_dir])
   701             # release.
   713       self.wc.commit('Branch of Melange release %s' % release,
   702             externals = self.wc.propget('svn:externals', 'vendor/soc')
   714                branch_dir)
   703             externals.append('%s -r %d %s' % (release, release_rev, tag_url))
   715       self._switchBranch(release)
   704             self.wc.propset('svn:externals', '\n'.join(externals), 'vendor/soc')
   716 
   705             self.wc.commit('Add svn:externals entry to pull in Melange '
   717       # Commit the production GSoC configuration and
   706                            'release %s at r%d.' % (release, release_rev))
   718       # google-specific patches.
   707 
   719       self._addAppYaml()
   708             # Export the tag into the release repository's branches
   720       self._applyGooglePatches()
   709             Subversion.export(tag_url, release_rev, self.wc.path(branch_dir))
   721 
   710 
   722       # All done!
   711             # Add and commit the branch add (very long operation!)
   723       log.info('Melange release %s imported and googlified' % self.branch)
   712             self.wc.add([branch_dir])
   724 
   713             self.wc.commit('Branch of Melange release %s' % release,
   725   @requires_branch
   714                            branch_dir)
   726   @pristine_wc
   715             self._switchBranch(release)
   727   def cherryPickChange(self):
   716 
   728     """Cherry-pick a change from the Melange trunk"""
   717             # Commit the production GSoC configuration and
   729     rev = getNumber('Revision number to cherry-pick:')
   718             # google-specific patches.
   730     bug = getNumber('Issue fixed by this change:')
   719             self._addAppYaml()
   731 
   720             self._applyGooglePatches()
   732     diff = self.wc.diff(self.upstream_repos + '/trunk', rev)
   721 
   733     if not diff.strip():
   722             # All done!
   734       raise ExpectationFailed(
   723             log.info('Melange release %s imported and googlified' % self.branch)
   735         'Retrieved diff is empty. '
   724 
   736         'Did you accidentally cherry-pick a branch change?')
   725     @requires_branch
   737     util.run(['patch', '-p0'], cwd=self.wc.path(self.branch_dir),
   726     @pristine_wc
   738          stdin=diff)
   727     def cherryPickChange(self):
   739     self.wc.addRemove(self.branch_dir)
   728         """Cherry-pick a change from the Melange trunk"""
   740 
   729         rev = getNumber('Revision number to cherry-pick:')
   741     yaml_path = self.wc.path(self._branchPath('app/app.yaml'))
   730         bug = getNumber('Issue fixed by this change:')
   742     out = []
   731 
   743     updated_patchlevel = False
   732         diff = self.wc.diff(self.upstream_repos + '/trunk', rev)
   744     for line in fileToLines(yaml_path):
   733         if not diff.strip():
   745       if line.strip().startswith('version: '):
   734             raise ExpectationFailed(
   746         version = line.strip().split()[-1]
   735                 'Retrieved diff is empty. '
   747         base, patch = line.rsplit('g', 1)
   736                 'Did you accidentally cherry-pick a branch change?')
   748         new_version = '%sg%d' % (base, int(patch) + 1)
   737         util.run(['patch', '-p0'], cwd=self.wc.path(self.branch_dir),
   749         message = ('Cherry-picked r%d from /p/soc/ to fix issue %d' %
   738                  stdin=diff)
   750                (rev, bug))
   739         self.wc.addRemove(self.branch_dir)
   751         out.append('version: ' + new_version)
   740 
   752         out.append('# * ' + message)
   741         yaml_path = self.wc.path(self._branchPath('app/app.yaml'))
   753         updated_patchlevel = True
   742         out = []
   754       else:
   743         updated_patchlevel = False
   755         out.append(line)
   744         for line in fileToLines(yaml_path):
   756 
   745             if line.strip().startswith('version: '):
   757     if not updated_patchlevel:
   746                 version = line.strip().split()[-1]
   758       log.error('Failed to update Google patch revision')
   747                 base, patch = line.rsplit('g', 1)
   759       log.error('Cherry-picking failed')
   748                 new_version = '%sg%d' % (base, int(patch) + 1)
   760 
   749                 message = ('Cherry-picked r%d from /p/soc/ to fix issue %d' %
   761     linesToFile(yaml_path, out)
   750                            (rev, bug))
   762 
   751                 out.append('version: ' + new_version)
   763     log.info('Check the diff about to be committed with:')
   752                 out.append('# * ' + message)
   764     log.info('svn diff ' + self.wc.path(self.branch_dir))
   753                 updated_patchlevel = True
   765     if not confirm('Commit this change?'):
   754             else:
   766       raise AbortedByUser('Cherry-pick aborted')
   755                 out.append(line)
   767     self.wc.commit(message)
   756 
   768     log.info('Cherry-picked r%d from the Melange trunk.' % rev)
   757         if not updated_patchlevel:
   769 
   758             log.error('Failed to update Google patch revision')
   770   MENU_ORDER = [
   759             log.error('Cherry-picking failed')
   771     update,
   760 
   772     switchToBranch,
   761         linesToFile(yaml_path, out)
   773     importTag,
   762 
   774     cherryPickChange,
   763         log.info('Check the diff about to be committed with:')
   775     ]
   764         log.info('svn diff ' + self.wc.path(self.branch_dir))
   776 
   765         if not confirm('Commit this change?'):
   777   MENU_STRINGS = [d.__doc__ for d in MENU_ORDER]
   766             raise AbortedByUser('Cherry-pick aborted')
   778 
   767         self.wc.commit(message)
   779   MENU_SUGGESTIONS = {
   768         log.info('Cherry-picked r%d from the Melange trunk.' % rev)
   780     None: update,
   769 
   781     update: cherryPickChange,
   770     MENU_ORDER = [
   782     switchToBranch: cherryPickChange,
   771         update,
   783     importTag: cherryPickChange,
   772         switchToBranch,
   784     cherryPickChange: None,
   773         importTag,
   785     }
   774         cherryPickChange,
   786 
   775         ]
   787   def interactiveMenu(self):
   776 
   788     done = []
   777     MENU_STRINGS = [d.__doc__ for d in MENU_ORDER]
   789     last_choice = None
   778 
   790     while True:
   779     MENU_SUGGESTIONS = {
   791       # Show the user their previously completed operations and
   780         None: update,
   792       # a suggested next op, to remind them where they are in
   781         update: cherryPickChange,
   793       # the release process (useful after long operations that
   782         switchToBranch: cherryPickChange,
   794       # may have caused lunch or an extended context switch).
   783         importTag: cherryPickChange,
   795       if last_choice is not None:
   784         cherryPickChange: None,
   796         last_command = self.MENU_ORDER[last_choice]
   785         }
   797       else:
   786 
   798         last_command = None
   787     def interactiveMenu(self):
   799       suggested_next = self.MENU_ORDER.index(
   788         done = []
   800         self.MENU_SUGGESTIONS[last_command])
   789         last_choice = None
   801 
   790         while True:
   802       try:
   791             # Show the user their previously completed operations and
   803         choice = getChoice('Main menu:', 'Your choice?',
   792             # a suggested next op, to remind them where they are in
   804                    self.MENU_STRINGS, done=done,
   793             # the release process (useful after long operations that
   805                    suggest=suggested_next)
   794             # may have caused lunch or an extended context switch).
   806       except (KeyboardInterrupt, AbortedByUser):
   795             if last_choice is not None:
   807         log.info('Exiting.')
   796                 last_command = self.MENU_ORDER[last_choice]
   808         return
   797             else:
   809       try:
   798                 last_command = None
   810         self.MENU_ORDER[choice](self)
   799             suggested_next = self.MENU_ORDER.index(
   811       except Error, e:
   800                 self.MENU_SUGGESTIONS[last_command])
   812         log.error(str(e))
   801 
   813       else:
   802             try:
   814         done.append(choice)
   803                 choice = getChoice('Main menu:', 'Your choice?',
   815         last_choice = choice
   804                                    self.MENU_STRINGS, done=done,
       
   805                                    suggest=suggested_next)
       
   806             except (KeyboardInterrupt, AbortedByUser):
       
   807                 log.info('Exiting.')
       
   808                 return
       
   809             try:
       
   810                 self.MENU_ORDER[choice](self)
       
   811             except Error, e:
       
   812                 log.error(str(e))
       
   813             else:
       
   814                 done.append(choice)
       
   815                 last_choice = choice
       
   816 
   816 
   817 
   817 
   818 def main(argv):
   818 def main(argv):
   819     if not (1 <= len(argv) <= 3):
   819   if not (1 <= len(argv) <= 3):
   820         print ('Usage: gsoc-release.py [release repos root URL] '
   820     print ('Usage: gsoc-release.py [release repos root URL] '
   821                '[upstream repos root URL]')
   821            '[upstream repos root URL]')
   822         sys.exit(1)
   822     sys.exit(1)
   823 
   823 
   824     release_repos, upstream_repos = GOOGLE_SOC_REPOS, MELANGE_REPOS
   824   release_repos, upstream_repos = GOOGLE_SOC_REPOS, MELANGE_REPOS
   825     if len(argv) >= 2:
   825   if len(argv) >= 2:
   826         release_repos = argv[1]
   826     release_repos = argv[1]
   827     if len(argv) == 3:
   827   if len(argv) == 3:
   828         upstream_repos = argv[2]
   828     upstream_repos = argv[2]
   829 
   829 
   830     log.init('release.log')
   830   log.init('release.log')
   831 
   831 
   832     log.info('Release repository: ' + release_repos)
   832   log.info('Release repository: ' + release_repos)
   833     log.info('Upstream repository: ' + upstream_repos)
   833   log.info('Upstream repository: ' + upstream_repos)
   834 
   834 
   835     r = ReleaseEnvironment(os.path.abspath('_release_'),
   835   r = ReleaseEnvironment(os.path.abspath('_release_'),
   836                            release_repos,
   836                          release_repos,
   837                            upstream_repos)
   837                          upstream_repos)
   838     r.interactiveMenu()
   838   r.interactiveMenu()
   839 
   839 
   840 
   840 
   841 if __name__ == '__main__':
   841 if __name__ == '__main__':
   842     main(sys.argv)
   842   main(sys.argv)