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