From 27ecaeb1b6b18127c9a982b5ae5d803c47223457 Mon Sep 17 00:00:00 2001
From: Clement <clement.brisset@gmail.com>
Date: Thu, 15 Nov 2018 16:06:47 -0800
Subject: [PATCH] Add RC branch creation script

---
 tools/scripts/rc-branches.py    | 241 +++++++++++++++++++++++++
 tools/scripts/utils/__init__.py |   1 +
 tools/scripts/utils/git.py      | 303 ++++++++++++++++++++++++++++++++
 3 files changed, 545 insertions(+)
 create mode 100755 tools/scripts/rc-branches.py
 create mode 100644 tools/scripts/utils/__init__.py
 create mode 100644 tools/scripts/utils/git.py

diff --git a/tools/scripts/rc-branches.py b/tools/scripts/rc-branches.py
new file mode 100755
index 0000000000..a9e1c8c304
--- /dev/null
+++ b/tools/scripts/rc-branches.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python
+
+import logging
+import os
+import re
+import sys
+
+from optparse import OptionParser
+
+from utils import git
+
+FORMAT = '[%(levelname)s] %(message)s'
+logging.basicConfig(format=FORMAT, level=logging.DEBUG)
+
+remote_name = "upstream"
+remote_master_branch = "{}/master".format(remote_name)
+
+def checkVersionBranches(version):
+    """Check the branches for a given version were created properly."""
+
+    repo = git.Repository(git.Repository.get_base_directory())
+
+    # Validate that user passed a valid version
+    VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
+    match = VERSION_RE.match(version)
+    if not match:
+        raise ValueError("Invalid version (should be X.Y.Z)")
+
+    # Parse the version component and build proper version strings
+    major = int(match.group(1))
+    minor = int(match.group(2))
+    patch = int(match.group(3))
+    version = "v{}.{}.{}".format(major, minor, patch) # clean up version
+
+    is_major_release = False
+    is_minor_release = False
+    is_patch_release = False
+    if patch != 0:
+        is_patch_release = True
+        previous_version = "v{}.{}.{}".format(major, minor, patch - 1)
+    elif minor != 0:
+        is_minor_release = True
+        previous_version = "v{}.{}.{}".format(major, minor - 1, 0)
+    else:
+        is_major_release = True
+        raise ValueError("Major releases not yet supported")
+
+    # Build the branch names
+    previous_rc_branch = "{}/{}-rc".format(remote_name, previous_version)
+    base_branch = "{}/{}-rc-base".format(remote_name, version)
+    rc_branch = "{}/{}-rc".format(remote_name, version)
+
+    # Verify the branches' existance
+    if not repo.does_branch_exist(previous_rc_branch):
+        raise ValueError("Previous RC branch not found: {}".format(previous_rc_branch))
+
+    if not repo.does_branch_exist(base_branch):
+        raise ValueError("Base branch not found: {}".format(base_branch))
+
+    if not repo.does_branch_exist(rc_branch):
+        raise ValueError("RC branch not found: {}".format(rc_branch))
+
+    # Figure out SHA for each of the branches
+    previous_rc_commit = repo.git_rev_parse([previous_rc_branch])
+    base_commit = repo.git_rev_parse([base_branch])
+    rc_commit = repo.git_rev_parse([rc_branch])
+
+    # Check the base branch is an ancestor of the rc branch
+    if not repo.is_ancestor(base_commit, rc_commit):
+        raise ValueError("{} is not an ancesctor of {}".format(base_branch, rc_branch))
+
+    # Check that the base branch is the merge base of the previous and current RCs
+    merge_base = repo.get_merge_base(previous_rc_commit, rc_commit)
+    if base_commit != merge_base:
+        raise ValueError("Base branch is not the merge base between {} and {}".format(previous_rc_branch, rc_branch))
+
+    # For patch releases, warn if the base commit is not the previous RC commit
+    if is_patch_release:
+        if not repo.does_tag_exist(version):
+            logging.warning("The tag {} does not exist, which suggests {} hasn't yet been cut.".format(version, version))
+
+        if base_commit != previous_rc_commit:
+            logging.warning("Previous version has commits not in this patch");
+            logging.warning("Type \"git diff {}..{}\" to see the commit list".format(base_commit, previous_rc_commit));
+
+        # Check base branch is part of the previous RC
+        previous_rc_base_commit = repo.get_merge_base(previous_rc_commit, remote_master_branch)
+        if repo.is_ancestor(base_commit, previous_rc_base_commit):
+            raise ValueError("{} is older than {}".format(base_branch, rc_branch))
+
+    print("[SUCCESS] Checked {}".format(version))
+
+def createVersionBranches(version):
+    """Create the branches for a given version."""
+
+    repo = git.Repository(git.Repository.get_base_directory())
+
+    # Validate the user is on a local branch that has the right merge base
+    if repo.is_detached():
+        raise ValueError("You must not run this script in a detached HEAD state")
+
+    # Validate the user has no pending changes
+    if repo.is_working_tree_dirty():
+        raise ValueError("Your working tree has pending changes. You must have a clean working tree before proceeding.")
+
+    # Validate that user passed a valid version
+    VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
+    match = VERSION_RE.match(version)
+    if not match:
+        raise ValueError("Invalid version (should be X.Y.Z)")
+
+    # Parse the version component and build proper version strings
+    major = int(match.group(1))
+    minor = int(match.group(2))
+    patch = int(match.group(3))
+    version = "v{}.{}.{}".format(major, minor, patch) # clean up version
+
+    is_major_release = False
+    is_minor_release = False
+    is_patch_release = False
+    if patch != 0:
+        is_patch_release = True
+        previous_version = "v{}.{}.{}".format(major, minor, patch - 1)
+    elif minor != 0:
+        is_minor_release = True
+        previous_version = "v{}.{}.{}".format(major, minor - 1, 0)
+    else:
+        is_major_release = True
+        raise ValueError("Major releases not yet supported")
+
+    # Build the branch names
+    previous_rc_branch = "{}-rc".format(previous_version)
+    base_branch = "{}-rc-base".format(version)
+    rc_branch = "{}-rc".format(version)
+    remote_previous_rc_branch = "{}/{}".format(remote_name, previous_rc_branch)
+    remote_base_branch = "{}/{}".format(remote_name, base_branch)
+    remote_rc_branch = "{}/{}".format(remote_name, rc_branch)
+
+    # Make sure the remote is up to date
+    repo.git_fetch([remote_name])
+
+    # Verify the previous RC branch exists
+    if not repo.does_branch_exist(remote_previous_rc_branch):
+        raise ValueError("Previous RC branch not found: {}".format(remote_previous_rc_branch))
+
+    # Verify the branches don't already exist
+    if repo.does_branch_exist(remote_base_branch):
+        raise ValueError("Base branch already exists: {}".format(remote_base_branch))
+
+    if repo.does_branch_exist(remote_rc_branch):
+        raise ValueError("RC branch already exists: {}".format(remote_rc_branch))
+
+    if repo.does_branch_exist(base_branch):
+        raise ValueError("Base branch already exists locally: {}".format(base_branch))
+
+    if repo.does_branch_exist(rc_branch):
+        raise ValueError("RC branch already exists locally: {}".format(rc_branch))
+
+    # Save current branch name
+    current_branch_name = repo.get_branch_name()
+
+    # Create the RC branches
+    if is_patch_release:
+
+        # Check tag exists, if it doesn't, print warning and ask for comfirmation
+        if not repo.does_tag_exist(version):
+            logging.warning("The tag {} does not exist, which suggests {} hasn't yet been cut.".format(version, version))
+            logging.warning("Creating the branches now means that they will diverge from {} if anything is merge into it.".format(previous_version))
+            logging.warning("This is not recommanded unless necessary.")
+
+            validAnswer = False
+            askCount = 0
+            while not validAnswer and askCount < 3:
+                answer = input("Are you sure you want to do this? [y/n]").strip().lower()
+                askCount += 1
+                validAnswer = answer == "y" or answer == "n"
+
+            if not validAnswer:
+                raise ValueError("Did not understand response")
+
+            if answer == "n":
+                print("Aborting")
+                return
+            else:
+                print("Creating branches")
+
+        repo.git_checkout(["-b", base_branch, remote_previous_rc_branch])
+        repo.push_to_remote_branch(remote_name, base_branch)
+        repo.git_checkout(["-b", rc_branch, remote_previous_rc_branch])
+        repo.push_to_remote_branch(remote_name, rc_branch)
+    else:
+        merge_base = repo.get_merge_base(remote_previous_rc_branch, remote_master_branch)
+        repo.git_checkout(["-b", base_branch, merge_base])
+        repo.push_to_remote_branch(remote_name, base_branch)
+        repo.git_checkout(["-b", rc_branch, remote_master_branch])
+        repo.push_to_remote_branch(remote_name, rc_branch)
+
+    repo.git_checkout([current_branch_name])
+
+    print("[SUCCESS] Created {} and {}".format(base_branch, rc_branch))
+    print("[SUCCESS] You can make the PR from the following webpage:")
+    print("[SUCCESS] https://github.com/highfidelity/hifi/compare/{}...{}".format(base_branch, rc_branch))
+
+def usage():
+    """Print usage."""
+    print("rc-branches.py supports the following commands:")
+    print("\ncheck <version>")
+    print("   <version>  - version to check of the form \"X.Y.Z\"")
+    print("\ncreate <version>")
+    print("   <version>  - version to create branches for of the form \"X.Y.Z\"")
+
+def main():
+    """Execute Main entry point."""
+    global remote_name
+
+    parser = OptionParser()
+    parser.add_option("-r", "--remote", type="string", dest="remote_name", default=remote_name)
+    (options, args) = parser.parse_args(args=sys.argv)
+
+    remote_name = options.remote_name
+
+    if len(args) < 3:
+        usage()
+        return
+
+    command = args[1]
+    version = args[2]
+
+    try:
+        if command == "check":
+            checkVersionBranches(version)
+        elif command == "create":
+            createVersionBranches(version)
+        else:
+            usage()
+    except Exception as ex:
+        logging.error(ex)
+        sys.exit(1)
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/scripts/utils/__init__.py b/tools/scripts/utils/__init__.py
new file mode 100644
index 0000000000..4b7a2bb941
--- /dev/null
+++ b/tools/scripts/utils/__init__.py
@@ -0,0 +1 @@
+"""Empty."""
diff --git a/tools/scripts/utils/git.py b/tools/scripts/utils/git.py
new file mode 100644
index 0000000000..156d2240c3
--- /dev/null
+++ b/tools/scripts/utils/git.py
@@ -0,0 +1,303 @@
+"""Module to run git commands on a repository."""
+
+# Copied from https://github.com/mongodb/mongo under Apache 2.0
+# Modified by Clement Brisset on 11/14/18.
+
+import logging
+import os
+import sys
+
+# The subprocess32 module resolves the thread-safety issues of the subprocess module in Python 2.x
+# when the _posixsubprocess C extension module is also available. Additionally, the _posixsubprocess
+# C extension module avoids triggering invalid free() calls on Python's internal data structure for
+# thread-local storage by skipping the PyOS_AfterFork() call when the 'preexec_fn' parameter isn't
+# specified to subprocess.Popen(). See SERVER-22219 for more details.
+#
+# The subprocess32 module is untested on Windows and thus isn't recommended for use, even when it's
+# installed. See https://github.com/google/python-subprocess32/blob/3.2.7/README.md#usage.
+if os.name == "posix" and sys.version_info[0] == 2:
+    try:
+        import subprocess32 as subprocess
+    except ImportError:
+        import warnings
+        warnings.warn(("Falling back to using the subprocess module because subprocess32 isn't"
+                       " available. When using the subprocess module, a child process may trigger"
+                       " an invalid free(). See SERVER-22219 for more details."), RuntimeWarning)
+        import subprocess  # type: ignore
+else:
+    import subprocess
+
+LOGGER = logging.getLogger(__name__)
+
+
+class Repository(object):  # pylint: disable=too-many-public-methods
+    """Represent a local git repository."""
+
+    def __init__(self, directory):
+        """Initialize Repository."""
+        self.directory = directory
+
+    def git_add(self, args):
+        """Run a git add command."""
+        return self._callgito("add", args)
+
+    def git_cat_file(self, args):
+        """Run a git cat-file command."""
+        return self._callgito("cat-file", args)
+
+    def git_checkout(self, args):
+        """Run a git checkout command."""
+        return self._callgito("checkout", args)
+
+    def git_commit(self, args):
+        """Run a git commit command."""
+        return self._callgito("commit", args)
+
+    def git_diff(self, args):
+        """Run a git diff command."""
+        return self._callgito("diff", args)
+
+    def git_log(self, args):
+        """Run a git log command."""
+        return self._callgito("log", args)
+
+    def git_push(self, args):
+        """Run a git push command."""
+        return self._callgito("push", args)
+
+    def git_fetch(self, args):
+        """Run a git fetch command."""
+        return self._callgito("fetch", args)
+
+    def git_ls_files(self, args):
+        """Run a git ls-files command and return the result as a str."""
+        return self._callgito("ls-files", args)
+
+    def git_rebase(self, args):
+        """Run a git rebase command."""
+        return self._callgito("rebase", args)
+
+    def git_reset(self, args):
+        """Run a git reset command."""
+        return self._callgito("reset", args)
+
+    def git_rev_list(self, args):
+        """Run a git rev-list command."""
+        return self._callgito("rev-list", args)
+
+    def git_rev_parse(self, args):
+        """Run a git rev-parse command."""
+        return self._callgito("rev-parse", args).rstrip()
+
+    def git_rm(self, args):
+        """Run a git rm command."""
+        return self._callgito("rm", args)
+
+    def git_show(self, args):
+        """Run a git show command."""
+        return self._callgito("show", args)
+
+    def get_origin_url(self):
+        """Return the URL of the origin repository."""
+        return self._callgito("config", ["--local", "--get", "remote.origin.url"]).rstrip()
+
+    def get_branch_name(self):
+        """
+        Get the current branch name, short form.
+
+        This returns "master", not "refs/head/master".
+        Raises a GitException if the current branch is detached.
+        """
+        branch = self.git_rev_parse(["--abbrev-ref", "HEAD"])
+        if branch == "HEAD":
+            raise GitException("Branch is currently detached")
+        return branch
+
+    def get_current_revision(self):
+        """Retrieve the current revision of the repository."""
+        return self.git_rev_parse(["HEAD"]).rstrip()
+
+    def configure(self, parameter, value):
+        """Set a local configuration parameter."""
+        return self._callgito("config", ["--local", parameter, value])
+
+    def is_detached(self):
+        """Return True if the current working tree in a detached HEAD state."""
+        # symbolic-ref returns 1 if the repo is in a detached HEAD state
+        return self._callgit("symbolic-ref", ["--quiet", "HEAD"]) == 1
+
+    def is_ancestor(self, parent_revision, child_revision):
+        """Return True if the specified parent hash an ancestor of child hash."""
+        # If the common point between parent_revision and child_revision is
+        # parent_revision, then parent_revision is an ancestor of child_revision.
+        merge_base = self._callgito("merge-base", [parent_revision, child_revision]).rstrip()
+        return parent_revision == merge_base
+
+    def is_commit(self, revision):
+        """Return True if the specified hash is a valid git commit."""
+        # cat-file -e returns 0 if it is a valid hash
+        return not self._callgit("cat-file", ["-e", "{0}^{{commit}}".format(revision)])
+
+    def is_working_tree_dirty(self):
+        """Return True if the current working tree has changes."""
+        # diff returns 1 if the working tree has local changes
+        return self._callgit("diff", ["--quiet"]) == 1
+
+    def does_branch_exist(self, branch):
+        """Return True if the branch exists."""
+        # rev-parse returns 0 if the branch exists
+        return not self._callgit("rev-parse", ["--verify", "--quiet", branch])
+
+    def does_tag_exist(self, tag):
+        """Return True if the tag exists."""
+        # rev-parse returns 0 if the tag exists
+        return not self._callgit("rev-parse", ["--verify", "--quiet", tag])
+
+    def get_merge_base(self, commit1, commit2 = "HEAD"):
+        """Get the merge base between 'commit' and HEAD."""
+        return self._callgito("merge-base", [commit1, commit2]).rstrip()
+
+    def commit_with_message(self, message):
+        """Commit the staged changes with the given message."""
+        return self.git_commit(["--message", message])
+
+    def push_to_remote_branch(self, remote, remote_branch):
+        """Push the current branch to the specified remote repository and branch."""
+        refspec = "{}:{}".format(self.get_branch_name(), remote_branch)
+        return self.git_push([remote, refspec])
+
+    def fetch_remote_branch(self, repository, branch):
+        """Fetch the changes from a remote branch."""
+        return self.git_fetch([repository, branch])
+
+    def rebase_from_upstream(self, upstream, ignore_date=False):
+        """Rebase the repository on an upstream reference.
+
+        If 'ignore_date' is True, the '--ignore-date' option is passed to git.
+        """
+        args = [upstream]
+        if ignore_date:
+            args.append("--ignore-date")
+        return self.git_rebase(args)
+
+    @staticmethod
+    def clone(url, directory, branch=None, depth=None):
+        """Clone the repository designed by 'url' into 'directory'.
+
+        Return a Repository instance.
+        """
+        params = ["git", "clone"]
+        if branch:
+            params += ["--branch", branch]
+        if depth:
+            params += ["--depth", depth]
+        params += [url, directory]
+        result = Repository._run_process("clone", params)
+        result.check_returncode()
+        return Repository(directory)
+
+    @staticmethod
+    def get_base_directory(directory=None):
+        """Return the base directory of the repository the given directory belongs to.
+
+        If no directory is specified, then the current working directory is used.
+        """
+        if directory is not None:
+            params = ["git", "-C", directory]
+        else:
+            params = ["git"]
+        params.extend(["rev-parse", "--show-toplevel"])
+        result = Repository._run_process("rev-parse", params)
+        result.check_returncode()
+        return os.path.normpath(result.stdout.rstrip())
+
+    @staticmethod
+    def current_repository():
+        """Return the Repository the current working directory belongs to."""
+        return Repository(Repository.get_base_directory())
+
+    def _callgito(self, cmd, args):
+        """Call git for this repository, and return the captured output."""
+        result = self._run_cmd(cmd, args)
+        result.check_returncode()
+        return result.stdout
+
+    def _callgit(self, cmd, args, raise_exception=False):
+        """
+        Call git for this repository without capturing output.
+
+        This is designed to be used when git returns non-zero exit codes.
+        """
+        result = self._run_cmd(cmd, args)
+        if raise_exception:
+            result.check_returncode()
+        return result.returncode
+
+    def _run_cmd(self, cmd, args):
+        """Run the git command and return a GitCommandResult instance."""
+
+        LOGGER.debug("Running: git {} {}".format(cmd, " ".join(args)))
+        params = ["git", cmd] + args
+        return self._run_process(cmd, params, cwd=self.directory)
+
+    @staticmethod
+    def _run_process(cmd, params, cwd=None):
+        process = subprocess.Popen(params, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)
+        (stdout, stderr) = process.communicate()
+        if process.returncode:
+            if stdout:
+                LOGGER.error("Output of '%s': %s", " ".join(params), stdout.rstrip())
+            if stderr:
+                LOGGER.error("Error output of '%s': %s", " ".join(params), stderr.rstrip())
+        return GitCommandResult(cmd, params, process.returncode, stdout=stdout, stderr=stderr)
+
+
+class GitException(Exception):
+    """Custom Exception for the git module.
+
+    Args:
+        message: the exception message.
+        returncode: the return code of the failed git command, if any.
+        cmd: the git subcommand that was run, if any.
+        process_args: a list containing the git command and arguments (includes 'git' as its first
+            element) that were run, if any.
+        stderr: the error output of the git command.
+    """
+
+    def __init__(  # pylint: disable=too-many-arguments
+            self, message, returncode=None, cmd=None, process_args=None, stdout=None, stderr=None):
+        """Initialize GitException."""
+        Exception.__init__(self, message)
+        self.returncode = returncode
+        self.cmd = cmd
+        self.process_args = process_args
+        self.stdout = stdout
+        self.stderr = stderr
+
+
+class GitCommandResult(object):
+    """The result of running git subcommand.
+
+    Args:
+        cmd: the git subcommand that was executed (e.g. 'clone', 'diff').
+        process_args: the full list of process arguments, starting with the 'git' command.
+        returncode: the return code.
+        stdout: the output of the command.
+        stderr: the error output of the command.
+    """
+
+    def __init__(  # pylint: disable=too-many-arguments
+            self, cmd, process_args, returncode, stdout=None, stderr=None):
+        """Initialize GitCommandResult."""
+        self.cmd = cmd
+        self.process_args = process_args
+        self.returncode = returncode
+        self.stdout = stdout
+        self.stderr = stderr
+
+    def check_returncode(self):
+        """Raise GitException if the exit code is non-zero."""
+        if self.returncode:
+            raise GitException("Command '{0}' failed with code '{1}'".format(
+                " ".join(self.process_args), self.returncode), self.returncode, self.cmd,
+                               self.process_args, self.stdout, self.stderr)