<gh_stars>0
#!/usr/bin/env python


from contextlib import contextmanager
from typing import List, Optional
import argparse
import logging

from git_helper import commit
from version_helper import (
    FILE_WITH_VERSION_PATH,
    ClickHouseVersion,
    VersionType,
    git,
    get_abs_path,
    get_version_from_repo,
    update_cmake_version,
)


class Release:
    BIG = ("major", "minor")
    SMALL = ("patch",)

    def __init__(self, version: ClickHouseVersion):
        self._version = version
        self._git = version._git
        self._release_commit = ""
        self._rollback_stack = []  # type: List[str]

    def run(self, cmd: str, cwd: Optional[str] = None) -> str:
        cwd_text = ""
        if cwd:
            cwd_text = f" (CWD='{cwd}')"
        logging.info("Running command%s:\n    %s", cwd_text, cmd)
        return self._git.run(cmd, cwd)

    def update(self):
        self._git.update()
        self.version = get_version_from_repo()

    def do(self, args: argparse.Namespace):
        self.release_commit = args.commit

        if not args.no_check_dirty:
            logging.info("Checking if repo is clean")
            self.run("git diff HEAD --exit-code")

        if not args.no_check_branch:
            self.check_branch(args.release_type)

        if args.release_type in self.BIG:
            # Checkout to the commit, it will provide the correct current version
            with self._checkout(self.release_commit, True):
                if args.no_prestable:
                    logging.info("Skipping prestable stage")
                else:
                    with self.prestable(args):
                        logging.info("Prestable part of the releasing is done")

                with self.testing(args):
                    logging.info("Testing part of the releasing is done")

        self.log_rollback()

    def check_no_tags_after(self):
        tags_after_commit = self.run(f"git tag --contains={self.release_commit}")
        if tags_after_commit:
            raise Exception(
                f"Commit {self.release_commit} belongs to following tags:\n"
                f"{tags_after_commit}\nChoose another commit"
            )

    def check_branch(self, release_type: str):
        if release_type in self.BIG:
            # Commit to spin up the release must belong to a main branch
            output = self.run(f"git branch --contains={self.release_commit} master")
            if "master" not in output:
                raise Exception(
                    f"commit {self.release_commit} must belong to 'master' for "
                    f"{release_type} release"
                )
        if release_type in self.SMALL:
            branch = f"{self.version.major}.{self.version.minor}"
            if self._git.branch != branch:
                raise Exception(f"branch must be '{branch}' for {release_type} release")

    def log_rollback(self):
        if self._rollback_stack:
            rollback = self._rollback_stack
            rollback.reverse()
            logging.info(
                "To rollback the action run the following commands:\n  %s",
                "\n  ".join(rollback),
            )

    @contextmanager
    def prestable(self, args: argparse.Namespace):
        self.check_no_tags_after()
        # Create release branch
        self.update()
        release_branch = f"{self.version.major}.{self.version.minor}"
        with self._create_branch(release_branch, self.release_commit):
            with self._checkout(release_branch, True):
                self.update()
                self.version.with_description(VersionType.PRESTABLE)
                with self._create_gh_release(args):
                    with self._bump_prestable_version(release_branch, args):
                        # At this point everything will rollback automatically
                        yield

    @contextmanager
    def testing(self, args: argparse.Namespace):
        # Create branch for a version bump
        self.update()
        self.version = self.version.update(args.release_type)
        helper_branch = f"{self.version.major}.{self.version.minor}-prepare"
        with self._create_branch(helper_branch, self.release_commit):
            with self._checkout(helper_branch, True):
                self.update()
                self.version = self.version.update(args.release_type)
                with self._bump_testing_version(helper_branch, args):
                    yield

    @property
    def version(self) -> ClickHouseVersion:
        return self._version

    @version.setter
    def version(self, version: ClickHouseVersion):
        if not isinstance(version, ClickHouseVersion):
            raise ValueError(f"version must be ClickHouseVersion, not {type(version)}")
        self._version = version

    @property
    def release_commit(self) -> str:
        return self._release_commit

    @release_commit.setter
    def release_commit(self, release_commit: str):
        self._release_commit = commit(release_commit)

    @contextmanager
    def _bump_prestable_version(self, release_branch: str, args: argparse.Namespace):
        self._git.update()
        new_version = self.version.patch_update()
        new_version.with_description("prestable")
        update_cmake_version(new_version)
        cmake_path = get_abs_path(FILE_WITH_VERSION_PATH)
        self.run(
            f"git commit -m 'Update version to {new_version.string}' '{cmake_path}'"
        )
        with self._push(release_branch, args):
            with self._create_gh_label(
                f"v{release_branch}-must-backport", "10dbed", args
            ):
                with self._create_gh_label(
                    f"v{release_branch}-affected", "c2bfff", args
                ):
                    self.run(
                        f"gh pr create --repo {args.repo} --title 'Release pull "
                        f"request for branch {release_branch}' --head {release_branch} "
                        "--body 'This PullRequest is a part of ClickHouse release "
                        "cycle. It is used by CI system only. Do not perform any "
                        "changes with it.' --label release"
                    )
                    # Here the prestable part is done
                    yield

    @contextmanager
    def _bump_testing_version(self, helper_branch: str, args: argparse.Namespace):
        self.version.with_description("testing")
        update_cmake_version(self.version)
        cmake_path = get_abs_path(FILE_WITH_VERSION_PATH)
        self.run(
            f"git commit -m 'Update version to {self.version.string}' '{cmake_path}'"
        )
        with self._push(helper_branch, args):
            body_file = get_abs_path(".github/PULL_REQUEST_TEMPLATE.md")
            self.run(
                f"gh pr create --repo {args.repo} --title 'Update version after "
                f"release' --head {helper_branch} --body-file '{body_file}'"
            )
            # Here the prestable part is done
            yield

    @contextmanager
    def _checkout(self, ref: str, with_checkout_back: bool = False):
        orig_ref = self._git.branch or self._git.sha
        need_rollback = False
        if ref not in (self._git.branch, self._git.sha):
            need_rollback = True
            self.run(f"git checkout {ref}")
            # checkout is not put into rollback_stack intentionally
            rollback_cmd = f"git checkout {orig_ref}"
        try:
            yield
        except BaseException:
            logging.warning("Rolling back checked out %s for %s", ref, orig_ref)
            self.run(f"git reset --hard; git checkout {orig_ref}")
            raise
        else:
            if with_checkout_back and need_rollback:
                self.run(rollback_cmd)

    @contextmanager
    def _create_branch(self, name: str, start_point: str = ""):
        self.run(f"git branch {name} {start_point}")
        rollback_cmd = f"git branch -D {name}"
        self._rollback_stack.append(rollback_cmd)
        try:
            yield
        except BaseException:
            logging.warning("Rolling back created branch %s", name)
            self.run(rollback_cmd)
            raise

    @contextmanager
    def _create_gh_label(self, label: str, color: str, args: argparse.Namespace):
        self.run(f"gh api repos/{args.repo}/labels -f name={label} -f color={color}")
        rollback_cmd = f"gh api repos/{args.repo}/labels/{label} -X DELETE"
        self._rollback_stack.append(rollback_cmd)
        try:
            yield
        except BaseException:
            logging.warning("Rolling back label %s", label)
            self.run(rollback_cmd)
            raise

    @contextmanager
    def _create_gh_release(self, args: argparse.Namespace):
        with self._create_tag(args):
            # Preserve tag if version is changed
            tag = self.version.describe
            self.run(
                f"gh release create --prerelease --draft --repo {args.repo} "
                f"--title 'Release {tag}' '{tag}'"
            )
            rollback_cmd = f"gh release delete --yes --repo {args.repo} '{tag}'"
            self._rollback_stack.append(rollback_cmd)
            try:
                yield
            except BaseException:
                logging.warning("Rolling back release publishing")
                self.run(rollback_cmd)
                raise

    @contextmanager
    def _create_tag(self, args: argparse.Namespace):
        tag = self.version.describe
        self.run(f"git tag -a -m 'Release {tag}' '{tag}'")
        rollback_cmd = f"git tag -d '{tag}'"
        self._rollback_stack.append(rollback_cmd)
        try:
            with self._push(f"'{tag}'", args):
                yield
        except BaseException:
            logging.warning("Rolling back tag %s", tag)
            self.run(rollback_cmd)
            raise

    @contextmanager
    def _push(self, ref: str, args: argparse.Namespace):
        self.run(f"git push <EMAIL>:{args.repo}.git {ref}")
        rollback_cmd = f"git push -d <EMAIL>:{args.repo}.git {ref}"
        self._rollback_stack.append(rollback_cmd)
        try:
            yield
        except BaseException:
            logging.warning("Rolling back pushed ref %s", ref)
            self.run(rollback_cmd)
            raise


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description="Script to release a new ClickHouse version, requires `git` and "
        "`gh` (github-cli) commands",
    )

    parser.add_argument(
        "--repo",
        default="ClickHouse/ClickHouse",
        help="repository to create the release",
    )
    parser.add_argument(
        "--type",
        default="minor",
        # choices=Release.BIG+Release.SMALL, # add support later
        choices=Release.BIG + Release.SMALL,
        dest="release_type",
        help="a release type, new branch is created only for 'major' and 'minor'",
    )
    parser.add_argument(
        "--no-prestable",
        action="store_true",
        help=f"for release types in {Release.BIG} skip creating prestable release and "
        "release branch",
    )
    parser.add_argument(
        "--commit",
        default=git.sha,
        type=commit,
        help="commit create a release, default to HEAD",
    )
    parser.add_argument(
        "--no-check-dirty",
        action="store_true",
        help="skip check repository for uncommited changes",
    )
    parser.add_argument(
        "--no-check-branch",
        action="store_true",
        help="by default, 'major' and 'minor' types work only for master, and 'patch' "
        "works only for a release branches, that name should be the same as "
        "'$MAJOR.$MINOR' version, e.g. 22.2",
    )

    return parser.parse_args()


def prestable():
    pass


def main():
    logging.basicConfig(level=logging.INFO)
    args = parse_args()
    release = Release(get_version_from_repo())

    release.do(args)


if __name__ == "__main__":
    main()

#!/usr/bin/env python


from contextlib import contextmanager
from typing import List, Optional
import argparse
import logging

from git_helper import commit
from version_helper import (
    FILE_WITH_VERSION_PATH,
    ClickHouseVersion,
    VersionType,
    git,
    get_abs_path,
    get_version_from_repo,
    update_cmake_version,
)


class Release:
    BIG = ("major", "minor")
    SMALL = ("patch",)

    def __init__(self, version: ClickHouseVersion):
        self._version = version
        self._git = version._git
        self._release_commit = ""
        self._rollback_stack = []  # type: List[str]

    def run(self, cmd: str, cwd: Optional[str] = None) -> str:
        cwd_text = ""
        if cwd:
            cwd_text = f" (CWD='{cwd}')"
        logging.info("Running command%s:\n    %s", cwd_text, cmd)
        return self._git.run(cmd, cwd)

    def update(self):
        self._git.update()
        self.version = get_version_from_repo()

    def do(self, args: argparse.Namespace):
        self.release_commit = args.commit

        if not args.no_check_dirty:
            logging.info("Checking if repo is clean")
            self.run("git diff HEAD --exit-code")

        if not args.no_check_branch:
            self.check_branch(args.release_type)

        if args.release_type in self.BIG:
            # Checkout to the commit, it will provide the correct current version
            with self._checkout(self.release_commit, True):
                if args.no_prestable:
                    logging.info("Skipping prestable stage")
                else:
                    with self.prestable(args):
                        logging.info("Prestable part of the releasing is done")

                with self.testing(args):
                    logging.info("Testing part of the releasing is done")

        self.log_rollback()

    def check_no_tags_after(self):
        tags_after_commit = self.run(f"git tag --contains={self.release_commit}")
        if tags_after_commit:
            raise Exception(
                f"Commit {self.release_commit} belongs to following tags:\n"
                f"{tags_after_commit}\nChoose another commit"
            )

    def check_branch(self, release_type: str):
        if release_type in self.BIG:
            # Commit to spin up the release must belong to a main branch
            output = self.run(f"git branch --contains={self.release_commit} master")
            if "master" not in output:
                raise Exception(
                    f"commit {self.release_commit} must belong to 'master' for "
                    f"{release_type} release"
                )
        if release_type in self.SMALL:
            branch = f"{self.version.major}.{self.version.minor}"
            if self._git.branch != branch:
                raise Exception(f"branch must be '{branch}' for {release_type} release")

    def log_rollback(self):
        if self._rollback_stack:
            rollback = self._rollback_stack
            rollback.reverse()
            logging.info(
                "To rollback the action run the following commands:\n  %s",
                "\n  ".join(rollback),
            )

    @contextmanager
    def prestable(self, args: argparse.Namespace):
        self.check_no_tags_after()
        # Create release branch
        self.update()
        release_branch = f"{self.version.major}.{self.version.minor}"
        with self._create_branch(release_branch, self.release_commit):
            with self._checkout(release_branch, True):
                self.update()
                self.version.with_description(VersionType.PRESTABLE)
                with self._create_gh_release(args):
                    with self._bump_prestable_version(release_branch, args):
                        # At this point everything will rollback automatically
                        yield

    @contextmanager
    def testing(self, args: argparse.Namespace):
        # Create branch for a version bump
        self.update()
        self.version = self.version.update(args.release_type)
        helper_branch = f"{self.version.major}.{self.version.minor}-prepare"
        with self._create_branch(helper_branch, self.release_commit):
            with self._checkout(helper_branch, True):
                self.update()
                self.version = self.version.update(args.release_type)
                with self._bump_testing_version(helper_branch, args):
                    yield

    @property
    def version(self) -> ClickHouseVersion:
        return self._version

    @version.setter
    def version(self, version: ClickHouseVersion):
        if not isinstance(version, ClickHouseVersion):
            raise ValueError(f"version must be ClickHouseVersion, not {type(version)}")
        self._version = version

    @property
    def release_commit(self) -> str:
        return self._release_commit

    @release_commit.setter
    def release_commit(self, release_commit: str):
        self._release_commit = commit(release_commit)

    @contextmanager
    def _bump_prestable_version(self, release_branch: str, args: argparse.Namespace):
        self._git.update()
        new_version = self.version.patch_update()
        new_version.with_description("prestable")
        update_cmake_version(new_version)
        cmake_path = get_abs_path(FILE_WITH_VERSION_PATH)
        self.run(
            f"git commit -m 'Update version to {new_version.string}' '{cmake_path}'"
        )
        with self._push(release_branch, args):
            with self._create_gh_label(
                f"v{release_branch}-must-backport", "10dbed", args
            ):
                with self._create_gh_label(
                    f"v{release_branch}-affected", "c2bfff", args
                ):
                    self.run(
                        f"gh pr create --repo {args.repo} --title 'Release pull "
                        f"request for branch {release_branch}' --head {release_branch} "
                        "--body 'This PullRequest is a part of ClickHouse release "
                        "cycle. It is used by CI system only. Do not perform any "
                        "changes with it.' --label release"
                    )
                    # Here the prestable part is done
                    yield

    @contextmanager
    def _bump_testing_version(self, helper_branch: str, args: argparse.Namespace):
        self.version.with_description("testing")
        update_cmake_version(self.version)
        cmake_path = get_abs_path(FILE_WITH_VERSION_PATH)
        self.run(
            f"git commit -m 'Update version to {self.version.string}' '{cmake_path}'"
        )
        with self._push(helper_branch, args):
            body_file = get_abs_path(".github/PULL_REQUEST_TEMPLATE.md")
            self.run(
                f"gh pr create --repo {args.repo} --title 'Update version after "
                f"release' --head {helper_branch} --body-file '{body_file}'"
            )
            # Here the prestable part is done
            yield

    @contextmanager
    def _checkout(self, ref: str, with_checkout_back: bool = False):
        orig_ref = self._git.branch or self._git.sha
        need_rollback = False
        if ref not in (self._git.branch, self._git.sha):
            need_rollback = True
            self.run(f"git checkout {ref}")
            # checkout is not put into rollback_stack intentionally
            rollback_cmd = f"git checkout {orig_ref}"
        try:
            yield
        except BaseException:
            logging.warning("Rolling back checked out %s for %s", ref, orig_ref)
            self.run(f"git reset --hard; git checkout {orig_ref}")
            raise
        else:
            if with_checkout_back and need_rollback:
                self.run(rollback_cmd)

    @contextmanager
    def _create_branch(self, name: str, start_point: str = ""):
        self.run(f"git branch {name} {start_point}")
        rollback_cmd = f"git branch -D {name}"
        self._rollback_stack.append(rollback_cmd)
        try:
            yield
        except BaseException:
            logging.warning("Rolling back created branch %s", name)
            self.run(rollback_cmd)
            raise

    @contextmanager
    def _create_gh_label(self, label: str, color: str, args: argparse.Namespace):
        self.run(f"gh api repos/{args.repo}/labels -f name={label} -f color={color}")
        rollback_cmd = f"gh api repos/{args.repo}/labels/{label} -X DELETE"
        self._rollback_stack.append(rollback_cmd)
        try:
            yield
        except BaseException:
            logging.warning("Rolling back label %s", label)
            self.run(rollback_cmd)
            raise

    @contextmanager
    def _create_gh_release(self, args: argparse.Namespace):
        with self._create_tag(args):
            # Preserve tag if version is changed
            tag = self.version.describe
            self.run(
                f"gh release create --prerelease --draft --repo {args.repo} "
                f"--title 'Release {tag}' '{tag}'"
            )
            rollback_cmd = f"gh release delete --yes --repo {args.repo} '{tag}'"
            self._rollback_stack.append(rollback_cmd)
            try:
                yield
            except BaseException:
                logging.warning("Rolling back release publishing")
                self.run(rollback_cmd)
                raise

    @contextmanager
    def _create_tag(self, args: argparse.Namespace):
        tag = self.version.describe
        self.run(f"git tag -a -m 'Release {tag}' '{tag}'")
        rollback_cmd = f"git tag -d '{tag}'"
        self._rollback_stack.append(rollback_cmd)
        try:
            with self._push(f"'{tag}'", args):
                yield
        except BaseException:
            logging.warning("Rolling back tag %s", tag)
            self.run(rollback_cmd)
            raise

    @contextmanager
    def _push(self, ref: str, args: argparse.Namespace):
        self.run(f"git push <EMAIL>:{args.repo}.git {ref}")
        rollback_cmd = f"git push -d <EMAIL>:{args.repo}.git {ref}"
        self._rollback_stack.append(rollback_cmd)
        try:
            yield
        except BaseException:
            logging.warning("Rolling back pushed ref %s", ref)
            self.run(rollback_cmd)
            raise


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description="Script to release a new ClickHouse version, requires `git` and "
        "`gh` (github-cli) commands",
    )

    parser.add_argument(
        "--repo",
        default="ClickHouse/ClickHouse",
        help="repository to create the release",
    )
    parser.add_argument(
        "--type",
        default="minor",
        # choices=Release.BIG+Release.SMALL, # add support later
        choices=Release.BIG + Release.SMALL,
        dest="release_type",
        help="a release type, new branch is created only for 'major' and 'minor'",
    )
    parser.add_argument(
        "--no-prestable",
        action="store_true",
        help=f"for release types in {Release.BIG} skip creating prestable release and "
        "release branch",
    )
    parser.add_argument(
        "--commit",
        default=git.sha,
        type=commit,
        help="commit create a release, default to HEAD",
    )
    parser.add_argument(
        "--no-check-dirty",
        action="store_true",
        help="skip check repository for uncommited changes",
    )
    parser.add_argument(
        "--no-check-branch",
        action="store_true",
        help="by default, 'major' and 'minor' types work only for master, and 'patch' "
        "works only for a release branches, that name should be the same as "
        "'$MAJOR.$MINOR' version, e.g. 22.2",
    )

    return parser.parse_args()


def prestable():
    pass


def main():
    logging.basicConfig(level=logging.INFO)
    args = parse_args()
    release = Release(get_version_from_repo())

    release.do(args)


if __name__ == "__main__":
    main()
#!/usr/bin/env python # type: List[str] # Checkout to the commit, it will provide the correct current version # Commit to spin up the release must belong to a main branch # Create release branch # At this point everything will rollback automatically # Create branch for a version bump # Here the prestable part is done # Here the prestable part is done # checkout is not put into rollback_stack intentionally # Preserve tag if version is changed # choices=Release.BIG+Release.SMALL, # add support later