From 0b15d4276fc1fc676076b143638d396dd6e2700a Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 14 Dec 2024 17:34:05 -0700 Subject: [PATCH 01/14] refactor: define a custom logging level of silly --- src/semantic_release/cli/commands/main.py | 13 ++++++++-- src/semantic_release/enums.py | 30 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/semantic_release/cli/commands/main.py b/src/semantic_release/cli/commands/main.py index c7ab3897e..885898dfb 100644 --- a/src/semantic_release/cli/commands/main.py +++ b/src/semantic_release/cli/commands/main.py @@ -15,6 +15,7 @@ from semantic_release.cli.config import GlobalCommandLineOptions from semantic_release.cli.const import DEFAULT_CONFIG_FILE from semantic_release.cli.util import rprint +from semantic_release.enums import SemanticReleaseLogLevels # if TYPE_CHECKING: # pass @@ -108,7 +109,15 @@ def main( """ console = Console(stderr=True) - log_level = [logging.WARNING, logging.INFO, logging.DEBUG][verbosity] + log_levels = [ + SemanticReleaseLogLevels.WARNING, + SemanticReleaseLogLevels.INFO, + SemanticReleaseLogLevels.DEBUG, + SemanticReleaseLogLevels.SILLY, + ] + + log_level = log_levels[verbosity] + logging.basicConfig( level=log_level, format=FORMAT, @@ -123,7 +132,7 @@ def main( logger = logging.getLogger(__name__) logger.debug("logging level set to: %s", logging.getLevelName(log_level)) - if log_level == logging.DEBUG: + if log_level <= logging.DEBUG: globals.debug = True if noop: diff --git a/src/semantic_release/enums.py b/src/semantic_release/enums.py index 26c539dff..c4fdbac56 100644 --- a/src/semantic_release/enums.py +++ b/src/semantic_release/enums.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from enum import IntEnum, unique @@ -37,3 +38,32 @@ def from_string(cls, val: str) -> LevelBump: >>> LevelBump.from_string("minor") == LevelBump.MINOR """ return cls[val.upper().replace("-", "_")] + + +class SemanticReleaseLogLevels(IntEnum): + """IntEnum representing the log levels used by semantic-release.""" + + FATAL = logging.FATAL + CRITICAL = logging.CRITICAL + ERROR = logging.ERROR + WARNING = logging.WARNING + INFO = logging.INFO + DEBUG = logging.DEBUG + SILLY = 5 + + def __str__(self) -> str: + """ + Return the level name rather than 'SemanticReleaseLogLevels.' + E.g. + >>> str(SemanticReleaseLogLevels.DEBUG) + 'DEBUG' + >>> str(SemanticReleaseLogLevels.CRITICAL) + 'CRITICAL' + """ + return self.name.upper() + + +logging.addLevelName( + SemanticReleaseLogLevels.SILLY, + str(SemanticReleaseLogLevels.SILLY), +) From a77c4695cadfb04ce8560d7b9ffa65c2d3bbd3e0 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 14 Dec 2024 17:38:44 -0700 Subject: [PATCH 02/14] fix(version): remove some excessive log msgs from debug to silly level --- src/semantic_release/version/algorithm.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index 1748e4212..2e1022daa 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -6,7 +6,7 @@ from semantic_release.commit_parser import ParsedCommit from semantic_release.const import DEFAULT_VERSION -from semantic_release.enums import LevelBump +from semantic_release.enums import LevelBump, SemanticReleaseLogLevels from semantic_release.errors import InvalidVersion, MissingMergeBaseError from semantic_release.version.version import Version @@ -158,7 +158,13 @@ def _increment_version( is in this branch's history. """ local_vars = list(locals().items()) - log.debug("_increment_version: %s", ", ".join(f"{k} = {v}" for k, v in local_vars)) + log.log( + SemanticReleaseLogLevels.SILLY, + "_increment_version: %s", + str.join(", ", [f"{k} = {v}" for k, v in local_vars]), + ) + + # Handle variations where the latest version is 0.x.x if latest_version.major == 0: if not allow_zero_version: # Set up default version to be 1.0.0 if currently 0.x.x which means a commented From 612def4c117990c3ac7ebdfd26f29beeebe62d8f Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 6 Dec 2024 23:25:20 -0700 Subject: [PATCH 03/14] test(fixtures): refactor builder functions for version file updates --- tests/fixtures/example_project.py | 70 ++++++++++++++++++++++++++----- tests/fixtures/git_repo.py | 37 +++++++++++----- tests/util.py | 11 +++-- 3 files changed, 95 insertions(+), 23 deletions(-) diff --git a/tests/fixtures/example_project.py b/tests/fixtures/example_project.py index 32da63155..83d0b6171 100644 --- a/tests/fixtures/example_project.py +++ b/tests/fixtures/example_project.py @@ -9,6 +9,9 @@ import pytest import tomlkit +# NOTE: use backport with newer API +from importlib_resources import files + import semantic_release from semantic_release.commit_parser import ( AngularCommitParser, @@ -30,13 +33,14 @@ EXAMPLE_SETUP_CFG_CONTENT, EXAMPLE_SETUP_PY_CONTENT, ) -from tests.util import copy_dir_tree +from tests.util import copy_dir_tree, temporary_working_directory if TYPE_CHECKING: from typing import Any, Protocol, Sequence from semantic_release.commit_parser import CommitParser from semantic_release.hvcs import HvcsBase + from semantic_release.version.version import Version from tests.conftest import ( BuildRepoOrCopyCacheFn, @@ -67,6 +71,9 @@ def __call__(self) -> type[CommitParser]: ... class UseReleaseNotesTemplateFn(Protocol): def __call__(self) -> None: ... + class UpdateVersionPyFileFn(Protocol): + def __call__(self, version: Version | str) -> None: ... + @pytest.fixture(scope="session") def deps_files_4_example_project() -> list[Path]: @@ -93,12 +100,14 @@ def build_spec_hash_4_example_project( @pytest.fixture(scope="session") def cached_example_project( build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + version_py_file: Path, pyproject_toml_file: Path, setup_cfg_file: Path, setup_py_file: Path, changelog_md_file: Path, changelog_rst_file: Path, build_spec_hash_4_example_project: str, + update_version_py_file: UpdateVersionPyFileFn, ) -> Path: """ Initializes the example project. DO NOT USE DIRECTLY @@ -108,12 +117,11 @@ def cached_example_project( def _build_project(cached_project_path: Path) -> Sequence[RepoActions]: # purposefully a relative path - example_dir = Path("src", EXAMPLE_PROJECT_NAME) - version_py = example_dir / "_version.py" + example_dir = version_py_file.parent gitignore_contents = dedent( f""" *.pyc - /src/**/{version_py.name} + /src/**/{version_py_file.name} """ ).lstrip() init_py_contents = dedent( @@ -128,15 +136,12 @@ def hello_world() -> None: print("Hello World") ''' ).lstrip() - version_py_contents = dedent( - f""" - __version__ = "{EXAMPLE_PROJECT_VERSION}" - """ - ).lstrip() + + with temporary_working_directory(cached_project_path): + update_version_py_file(EXAMPLE_PROJECT_VERSION) file_2_contents: list[tuple[str | Path, str]] = [ (example_dir / "__init__.py", init_py_contents), - (version_py, version_py_contents), (".gitignore", gitignore_contents), (pyproject_toml_file, EXAMPLE_PYPROJECT_TOML_CONTENT), (setup_cfg_file, EXAMPLE_SETUP_CFG_CONTENT), @@ -188,6 +193,11 @@ def example_project_with_release_notes_template( use_release_notes_template() +@pytest.fixture(scope="session") +def version_py_file() -> Path: + return Path("src", EXAMPLE_PROJECT_NAME, "_version.py") + + @pytest.fixture(scope="session") def pyproject_toml_file() -> Path: return Path("pyproject.toml") @@ -233,6 +243,30 @@ def default_rst_changelog_insertion_flag() -> str: return f"..{os.linesep} version list" +@pytest.fixture(scope="session") +def default_changelog_md_template() -> Path: + """Retrieve the semantic-release default changelog template file""" + return Path( + str( + files(semantic_release.__name__).joinpath( + Path("data", "templates", "angular", "md", "CHANGELOG.md.j2") + ) + ) + ).resolve() + + +@pytest.fixture(scope="session") +def default_changelog_rst_template() -> Path: + """Retrieve the semantic-release default changelog template file""" + return Path( + str( + files(semantic_release.__name__).joinpath( + Path("data", "templates", "angular", "rst", "CHANGELOG.rst.j2") + ) + ) + ).resolve() + + @pytest.fixture(scope="session") def get_wheel_file(dist_dir: Path) -> GetWheelFileFn: def _get_wheel_file(version_str: str) -> Path: @@ -346,6 +380,22 @@ def example_project_template_dir( return example_project_dir / changelog_template_dir +@pytest.fixture(scope="session") +def update_version_py_file(version_py_file: Path) -> UpdateVersionPyFileFn: + def _update_version_py_file(version: Version | str) -> None: + cwd_version_py = version_py_file.resolve() + cwd_version_py.parent.mkdir(parents=True, exist_ok=True) + cwd_version_py.write_text( + dedent( + f"""\ + __version__ = "{version}" + """ + ) + ) + + return _update_version_py_file + + @pytest.fixture(scope="session") def update_pyproject_toml(pyproject_toml_file: Path) -> UpdatePyprojectTomlFn: """Update the pyproject.toml file with the given content.""" diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 360016313..5d6897255 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -29,13 +29,14 @@ from tests.util import ( add_text_to_file, copy_dir_tree, - shortuid, temporary_working_directory, ) if TYPE_CHECKING: from typing import Generator, Literal, Protocol, Sequence, TypedDict, Union + from tests.fixtures.example_project import UpdateVersionPyFileFn + try: # Python 3.8 and 3.9 compatibility from typing_extensions import TypeAlias @@ -360,6 +361,9 @@ def __call__( RepoActionGitMerge, ] + class GetGitRepo4DirFn(Protocol): + def __call__(self, directory: Path | str) -> Repo: ... + @pytest.fixture(scope="session") def deps_files_4_example_git_project( @@ -450,7 +454,7 @@ def default_tag_format_str() -> str: @pytest.fixture(scope="session") def file_in_repo(): - return f"file-{shortuid()}.txt" + return "file.txt" @pytest.fixture(scope="session") @@ -758,6 +762,7 @@ def _create_squash_merge_commit( @pytest.fixture(scope="session") def create_release_tagged_commit( update_pyproject_toml: UpdatePyprojectTomlFn, + update_version_py_file: UpdateVersionPyFileFn, default_tag_format_str: str, stable_now_date: GetStableDateNowFn, ) -> CreateReleaseFn: @@ -775,6 +780,9 @@ def _mimic_semantic_release_commit( if curr_dt == commit_dt: sleep(1) # ensure commit timestamps are unique + # stamp version into version file + update_version_py_file(version) + # stamp version into pyproject.toml update_pyproject_toml("tool.poetry.version", version) @@ -1637,22 +1645,31 @@ def _mimic_semantic_release_default_changelog( @pytest.fixture -def example_project_git_repo( - example_project_dir: ExProjectDir, -) -> Generator[ExProjectGitRepoFn, None, None]: +def git_repo_for_directory() -> Generator[GetGitRepo4DirFn, None, None]: repos: list[Repo] = [] # Must be a callable function to ensure files exist before repo is opened - def _example_project_git_repo() -> Repo: - if not example_project_dir.exists(): - raise RuntimeError("Unable to find example git project!") + def _git_repo_4_dir(directory: Path | str) -> Repo: + if not Path(directory).exists(): + raise RuntimeError("Unable to find git project!") - repo = Repo(example_project_dir) + repo = Repo(directory) repos.append(repo) return repo try: - yield _example_project_git_repo + yield _git_repo_4_dir finally: for repo in repos: repo.close() + + +@pytest.fixture +def example_project_git_repo( + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, +) -> ExProjectGitRepoFn: + def _example_project_git_repo() -> Repo: + return git_repo_for_directory(example_project_dir) + + return _example_project_git_repo diff --git a/tests/util.py b/tests/util.py index 194d1f377..97dc18b5c 100644 --- a/tests/util.py +++ b/tests/util.py @@ -151,9 +151,14 @@ def shortuid(length: int = 8) -> str: def add_text_to_file(repo: Repo, filename: str, text: str | None = None): - with open(f"{repo.working_tree_dir}/{filename}", "a+") as f: - f.write(text or f"default text {shortuid(12)}") - f.write(os.linesep) + """Makes a deterministic file change for testing""" + tgt_file = Path(repo.working_tree_dir or ".") / filename + tgt_file.parent.mkdir(parents=True, exist_ok=True) + file_contents = tgt_file.read_text() if tgt_file.exists() else "" + line_number = len(file_contents.splitlines()) + + file_contents += f"{line_number} {text or 'default text'}{os.linesep}" + tgt_file.write_text(file_contents, encoding="utf-8") repo.index.add(filename) From 4de726477136903814aa28405627df5997086741 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 8 Dec 2024 15:59:58 -0700 Subject: [PATCH 04/14] test(fixtures): adjust build command to handle versions w/ build metadata --- tests/fixtures/git_repo.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 5d6897255..82b800583 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -936,15 +936,19 @@ def _build_configured_base_repo( # noqa: C901 update_pyproject_toml( # NOTE: must work in both bash and Powershell "tool.semantic_release.build_command", + # NOTE: we are trying to ensure a few non-file-path characters are removed, but this is not + # the equivalent of a cononcial version translator, so it may not work in all cases dedent( f"""\ mkdir -p "{build_result_file.parent}" - touch "{build_result_file}" + WHEEL_FILE="$(printf '%s' "{build_result_file}" | sed 's/+/./g')" + touch "$WHEEL_FILE" """ if sys.platform != "win32" else f"""\ mkdir {build_result_file.parent} > $null - New-Item -ItemType file -Path "{build_result_file}" -Force | Select-Object OriginalPath + $WHEEL_FILE = "{build_result_file}".Replace('+', '.') + New-Item -ItemType file -Path "$WHEEL_FILE" -Force | Select-Object OriginalPath """ ), ) From d2932b66dd7e363fadcb731e543e150ee28fb9dc Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 8 Dec 2024 16:01:34 -0700 Subject: [PATCH 05/14] test(fixtures): fix gitflow repo that included an invalid build metadata string --- tests/fixtures/repos/git_flow/repo_w_4_release_channels.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py index e12c01fc1..204a41ea3 100644 --- a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py @@ -612,7 +612,8 @@ def _get_repo_from_defintion( ) # Add a new feature (another developer was working on) and create a release for it - new_version = f"1.1.0-rev.1+{FEAT_BRANCH_2_NAME}" + # Based on Semver standard, Build metadata is restricted to [A-Za-z0-9-] so we replace the '/' with a '-' + new_version = f"""1.1.0-rev.1+{FEAT_BRANCH_2_NAME.replace("/", '-')}""" repo_construction_steps.extend( [ *fast_forward_dev_branch_actions, From 0eeb6c66fec1dbb699b63448914d95a58562d582 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Mon, 9 Dec 2024 00:11:00 -0700 Subject: [PATCH 06/14] test(fixtures): fix major_on_zero setting in repos to match expected behavior --- tests/fixtures/repos/git_flow/repo_w_1_release_channel.py | 2 +- tests/fixtures/repos/git_flow/repo_w_2_release_channels.py | 2 +- tests/fixtures/repos/git_flow/repo_w_3_release_channels.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py index 16524bf7e..75b614245 100644 --- a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py +++ b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py @@ -182,7 +182,7 @@ def _get_repo_from_defintion( "prerelease": False, }, "tool.semantic_release.allow_zero_version": True, - "tool.semantic_release.major_on_zero": False, + "tool.semantic_release.major_on_zero": True, **(extra_configs or {}), }, }, diff --git a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py index b1b39eff0..52e8cdb36 100644 --- a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py @@ -188,7 +188,7 @@ def _get_repo_from_defintion( "prerelease_token": "alpha", }, "tool.semantic_release.allow_zero_version": True, - "tool.semantic_release.major_on_zero": False, + "tool.semantic_release.major_on_zero": True, **(extra_configs or {}), }, }, diff --git a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py index 2cbbdd2fa..32f22a7c6 100644 --- a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py @@ -196,7 +196,7 @@ def _get_repo_from_defintion( "prerelease_token": "alpha", }, "tool.semantic_release.allow_zero_version": True, - "tool.semantic_release.major_on_zero": False, + "tool.semantic_release.major_on_zero": True, **(extra_configs or {}), }, }, From 4db8c48b4ee3a96e2531d31e20f2d8ee8e9c1f09 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 6 Dec 2024 23:28:12 -0700 Subject: [PATCH 07/14] test(cmd-version): add test cases to run an example repo rebuild w/ psr --- .../e2e/cmd_version/bump_version/__init__.py | 0 .../e2e/cmd_version/bump_version/conftest.py | 135 ++++++++++++ .../bump_version/git_flow/__init__.py | 0 .../git_flow/test_repo_1_channel.py | 175 ++++++++++++++++ .../git_flow/test_repo_2_channels.py | 175 ++++++++++++++++ .../git_flow/test_repo_3_channels.py | 177 ++++++++++++++++ .../git_flow/test_repo_4_channels.py | 175 ++++++++++++++++ .../bump_version/github_flow/__init__.py | 0 .../github_flow/test_repo_1_channel.py | 174 ++++++++++++++++ .../github_flow/test_repo_2_channels.py | 174 ++++++++++++++++ .../bump_version/trunk_based_dev/__init__.py | 0 .../trunk_based_dev/test_repo_trunk.py | 174 ++++++++++++++++ .../test_repo_trunk_w_prereleases.py | 194 ++++++++++++++++++ tests/fixtures/git_repo.py | 57 +++++ 14 files changed, 1610 insertions(+) create mode 100644 tests/e2e/cmd_version/bump_version/__init__.py create mode 100644 tests/e2e/cmd_version/bump_version/conftest.py create mode 100644 tests/e2e/cmd_version/bump_version/git_flow/__init__.py create mode 100644 tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py create mode 100644 tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py create mode 100644 tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py create mode 100644 tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py create mode 100644 tests/e2e/cmd_version/bump_version/github_flow/__init__.py create mode 100644 tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py create mode 100644 tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py create mode 100644 tests/e2e/cmd_version/bump_version/trunk_based_dev/__init__.py create mode 100644 tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py create mode 100644 tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py diff --git a/tests/e2e/cmd_version/bump_version/__init__.py b/tests/e2e/cmd_version/bump_version/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/conftest.py b/tests/e2e/cmd_version/bump_version/conftest.py new file mode 100644 index 000000000..ecec668a9 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/conftest.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +import shutil +from re import IGNORECASE, compile as regexp +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +if TYPE_CHECKING: + from pathlib import Path + from re import Pattern + from typing import Protocol + + from tests.fixtures.git_repo import BuildRepoFromDefinitionFn, RepoActionConfigure + + class GetSanitizedMdChangelogContentFn(Protocol): + def __call__(self, repo_dir: Path) -> str: ... + + class GetSanitizedRstChangelogContentFn(Protocol): + def __call__(self, repo_dir: Path) -> str: ... + + class InitMirrorRepo4RebuildFn(Protocol): + def __call__( + self, + mirror_repo_dir: Path, + configuration_step: RepoActionConfigure, + ) -> Path: ... + + +@pytest.fixture(scope="session") +def init_mirror_repo_for_rebuild( + default_changelog_md_template: Path, + default_changelog_rst_template: Path, + changelog_template_dir: Path, + build_repo_from_definition: BuildRepoFromDefinitionFn, +) -> InitMirrorRepo4RebuildFn: + def _init_mirror_repo_for_rebuild( + mirror_repo_dir: Path, + configuration_step: RepoActionConfigure, + ) -> Path: + # Create the mirror repo directory + mirror_repo_dir.mkdir(exist_ok=True, parents=True) + + # Initialize mirror repository + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=[configuration_step], + ) + + # Force custom changelog to be a copy of the default changelog (md and rst) + shutil.copytree( + src=default_changelog_md_template.parent, + dst=mirror_repo_dir / changelog_template_dir, + dirs_exist_ok=True, + ) + shutil.copytree( + src=default_changelog_rst_template.parent, + dst=mirror_repo_dir / changelog_template_dir, + dirs_exist_ok=True, + ) + + with Repo(mirror_repo_dir) as mirror_git_repo: + mirror_git_repo.git.add(str(changelog_template_dir)) + + return mirror_repo_dir + + return _init_mirror_repo_for_rebuild + + +@pytest.fixture(scope="session") +def long_hash_pattern() -> Pattern: + return regexp(r"\b([0-9a-f]{40})\b", IGNORECASE) + + +@pytest.fixture(scope="session") +def short_hash_pattern() -> Pattern: + return regexp(r"\b([0-9a-f]{7})\b", IGNORECASE) + + +@pytest.fixture(scope="session") +def get_sanitized_rst_changelog_content( + changelog_rst_file: Path, + default_rst_changelog_insertion_flag: str, + long_hash_pattern: Pattern, + short_hash_pattern: Pattern, +) -> GetSanitizedRstChangelogContentFn: + rst_short_hash_link_pattern = regexp(r"(_[0-9a-f]{7})\b", IGNORECASE) + + def _get_sanitized_rst_changelog_content(repo_dir: Path) -> str: + # TODO: v10 change -- default turns to update so this is not needed + # Because we are in init mode, the insertion flag is not present in the changelog + # we must take it out manually because our repo generation fixture includes it automatically + with (repo_dir / changelog_rst_file).open(newline=os.linesep) as rfd: + # use os.linesep here because the insertion flag is os-specific + # but convert the content to universal newlines for comparison + changelog_content = ( + rfd.read() + .replace(f"{default_rst_changelog_insertion_flag}{os.linesep}", "") + .replace("\r", "") + ) + + changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) + changelog_content = short_hash_pattern.sub("0" * 7, changelog_content) + return rst_short_hash_link_pattern.sub(f'_{"0" * 7}', changelog_content) + + return _get_sanitized_rst_changelog_content + + +@pytest.fixture(scope="session") +def get_sanitized_md_changelog_content( + changelog_md_file: Path, + default_md_changelog_insertion_flag: str, + long_hash_pattern: Pattern, + short_hash_pattern: Pattern, +) -> GetSanitizedMdChangelogContentFn: + def _get_sanitized_md_changelog_content(repo_dir: Path) -> str: + # TODO: v10 change -- default turns to update so this is not needed + # Because we are in init mode, the insertion flag is not present in the changelog + # we must take it out manually because our repo generation fixture includes it automatically + with (repo_dir / changelog_md_file).open(newline=os.linesep) as rfd: + # use os.linesep here because the insertion flag is os-specific + # but convert the content to universal newlines for comparison + changelog_content = ( + rfd.read() + .replace(f"{default_md_changelog_insertion_flag}{os.linesep}", "") + .replace("\r", "") + ) + + changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) + + return short_hash_pattern.sub("0" * 7, changelog_content) + + return _get_sanitized_md_changelog_content diff --git a/tests/e2e/cmd_version/bump_version/git_flow/__init__.py b/tests/e2e/cmd_version/bump_version/git_flow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py new file mode 100644 index 000000000..336de7f59 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_angular_commits, + repo_w_git_flow_emoji_commits, + repo_w_git_flow_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.skip("Currently failing due to git flow releasing bug") +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_angular_commits.__name__, + repo_w_git_flow_emoji_commits.__name__, + repo_w_git_flow_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_1_channel( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_1_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_1_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py new file mode 100644 index 000000000..5ee8c2798 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_w_alpha_prereleases_n_angular_commits, + repo_w_git_flow_w_alpha_prereleases_n_emoji_commits, + repo_w_git_flow_w_alpha_prereleases_n_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.skip("Currently failing due to git flow releasing bug") +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_w_alpha_prereleases_n_angular_commits.__name__, + repo_w_git_flow_w_alpha_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_alpha_prereleases_n_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_2_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_2_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_2_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py new file mode 100644 index 000000000..2c7c307e9 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.skip("Currently failing due to git flow releasing bug") +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_3_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_3_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_3_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py new file mode 100644 index 000000000..d6e644b3d --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_angular_commits, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_emoji_commits, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.skip("Currently failing due to git flow releasing bug") +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_angular_commits.__name__, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_scipy_commits.__name__, + ], +) +def test_gitflow_repo_rebuild_4_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_git_flow_repo_w_4_release_channels: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_git_flow_repo_w_4_release_channels( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/github_flow/__init__.py b/tests/e2e/cmd_version/bump_version/github_flow/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py new file mode 100644 index 000000000..796f0fcfe --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.github_flow import ( + repo_w_github_flow_w_default_release_channel_angular_commits, + repo_w_github_flow_w_default_release_channel_emoji_commits, + repo_w_github_flow_w_default_release_channel_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_github_flow_w_default_release_channel_angular_commits.__name__, + repo_w_github_flow_w_default_release_channel_emoji_commits.__name__, + repo_w_github_flow_w_default_release_channel_scipy_commits.__name__, + ], +) +def test_githubflow_repo_rebuild_1_channel( + repo_fixture_name: str, + cli_runner: CliRunner, + build_repo_w_github_flow_w_default_release_channel: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_w_github_flow_w_default_release_channel( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py new file mode 100644 index 000000000..4f50dd805 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.github_flow import ( + repo_w_github_flow_w_feature_release_channel_angular_commits, + repo_w_github_flow_w_feature_release_channel_emoji_commits, + repo_w_github_flow_w_feature_release_channel_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_github_flow_w_feature_release_channel_angular_commits.__name__, + repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__, + repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__, + ], +) +def test_githubflow_repo_rebuild_2_channels( + repo_fixture_name: str, + cli_runner: CliRunner, + build_repo_w_github_flow_w_feature_release_channel: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] # type: ignore[assignment] + ) + target_repo_definition = build_repo_w_github_flow_w_feature_release_channel( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/__init__.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py new file mode 100644 index 000000000..40f4a6fe8 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_angular_commits, + repo_w_trunk_only_emoji_commits, + repo_w_trunk_only_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_trunk_only_angular_commits.__name__, + repo_w_trunk_only_emoji_commits.__name__, + repo_w_trunk_only_scipy_commits.__name__, + ], +) +def test_trunk_repo_rebuild_only_official_releases( + repo_fixture_name: str, + cli_runner: CliRunner, + build_trunk_only_repo_w_tags: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_trunk_only_repo_w_tags( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py new file mode 100644 index 000000000..cce770333 --- /dev/null +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from flatdict import FlatDict +from freezegun import freeze_time + +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_n_prereleases_angular_commits, + repo_w_trunk_only_n_prereleases_emoji_commits, + repo_w_trunk_only_n_prereleases_scipy_commits, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + from click.testing import CliRunner + from requests_mock import Mocker + + from tests.e2e.cmd_version.bump_version.conftest import ( + GetSanitizedMdChangelogContentFn, + GetSanitizedRstChangelogContentFn, + InitMirrorRepo4RebuildFn, + ) + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuildSpecificRepoFn, + CommitConvention, + GetGitRepo4DirFn, + RepoActionConfigure, + RepoActionRelease, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name", + [ + repo_w_trunk_only_n_prereleases_angular_commits.__name__, + repo_w_trunk_only_n_prereleases_emoji_commits.__name__, + repo_w_trunk_only_n_prereleases_scipy_commits.__name__, + ], +) +def test_trunk_repo_rebuild_w_prereleases( + repo_fixture_name: str, + cli_runner: CliRunner, + build_trunk_only_repo_w_prerelease_tags: BuildSpecificRepoFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, + default_tag_format_str: str, + version_py_file: Path, + get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, +): + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_trunk_only_repo_w_prerelease_tags( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + target_repo_pyproject_toml = FlatDict( + tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")), + delimiter=".", + ) + tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment] + "tool.semantic_release.tag_format", + default_tag_format_str, + ) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(target_repo_definition, tag_format_str) + ) + configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment] + + # Create the mirror repo directory + mirror_repo_dir = init_mirror_repo_for_rebuild( + mirror_repo_dir=(example_project_dir / "mirror"), + configuration_step=configuration_step, + ) + mirror_git_repo = git_repo_for_directory(mirror_repo_dir) + + # rebuild repo from scratch stopping before each release tag + for curr_release_tag, steps in releasetags_2_steps.items(): + # make sure mocks are clear + mocked_git_push.reset_mock() + post_mocker.reset_mock() + + # Extract expected result from target repo + target_git_repo.git.checkout(curr_release_tag, detach=True) + expected_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=target_repo_dir + ) + expected_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=target_repo_dir + ) + expected_pyproject_toml_content = ( + target_repo_dir / "pyproject.toml" + ).read_text() + expected_version_file_content = (target_repo_dir / version_py_file).read_text() + expected_release_commit_text = target_git_repo.head.commit.message + + # In our repo env, start building the repo from the definition + build_repo_from_definition( + dest_dir=mirror_repo_dir, + repo_construction_steps=steps[:-1], # stop before the release step + ) + release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment] + + # Act: run PSR on the repo instead of the RELEASE step + with freeze_time( + release_action_step["details"]["datetime"] + ), temporary_working_directory(mirror_repo_dir): + build_metadata_args = ( + [ + "--build-metadata", + release_action_step["details"]["version"].split("+", maxsplit=1)[ + -1 + ], + ] + if len(release_action_step["details"]["version"].split("+", maxsplit=1)) + > 1 + else [] + ) + prerelease_args = ( + [ + "--as-prerelease", + "--prerelease-token", + ( + release_action_step["details"]["version"] + .split("-", maxsplit=1)[-1] + .split(".", maxsplit=1)[0] + ), + ] + if len(release_action_step["details"]["version"].split("-", maxsplit=1)) + > 1 + else [] + ) + cli_cmd = [ + MAIN_PROG_NAME, + "--strict", + VERSION_SUBCMD, + *build_metadata_args, + *prerelease_args, + ] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + actual_release_commit_text = mirror_git_repo.head.commit.message + actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() + actual_version_file_content = (mirror_repo_dir / version_py_file).read_text() + actual_md_changelog_content = get_sanitized_md_changelog_content( + repo_dir=mirror_repo_dir + ) + actual_rst_changelog_content = get_sanitized_rst_changelog_content( + repo_dir=mirror_repo_dir + ) + + # Evaluate (normal release actions should have occurred as expected) + assert_successful_exit_code(result, cli_cmd) + # Make sure version file is updated + assert expected_pyproject_toml_content == actual_pyproject_toml_content + assert expected_version_file_content == actual_version_file_content + # Make sure changelog is updated + assert expected_md_changelog_content == actual_md_changelog_content + assert expected_rst_changelog_content == actual_rst_changelog_content + # Make sure commit is created + assert expected_release_commit_text == actual_release_commit_text + # Make sure tag is created + assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag + assert post_mocker.call_count == 1 # vcs release creation occured diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 82b800583..b5c872007 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -364,6 +364,11 @@ def __call__( class GetGitRepo4DirFn(Protocol): def __call__(self, directory: Path | str) -> Repo: ... + class SplitRepoActionsByReleaseTagsFn(Protocol): + def __call__( + self, repo_definition: Sequence[RepoActions], tag_format_str: str + ) -> dict[str, list[RepoActions]]: ... + @pytest.fixture(scope="session") def deps_files_4_example_git_project( @@ -1265,6 +1270,58 @@ def _get_commits( return _get_commits +@pytest.fixture(scope="session") +def split_repo_actions_by_release_tags( + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, +) -> SplitRepoActionsByReleaseTagsFn: + def _split_repo_actions_by_release_tags( + repo_definition: Sequence[RepoActions], + tag_format_str: str, + ) -> dict[str, list[RepoActions]]: + releasetags_2_steps: dict[str, list[RepoActions]] = { + "": [], + } + + # Create generator for next release tags + next_release_tag_gen = ( + tag_format_str.format(version=version) + for version in get_versions_from_repo_build_def(repo_definition) + ) + + # initialize the first release tag + curr_release_tag = next(next_release_tag_gen) + releasetags_2_steps[curr_release_tag] = [] + + # Loop through all actions and split them by release tags + for step in repo_definition: + if step["action"] == RepoActionStep.CONFIGURE: + releasetags_2_steps[""].append(step) + continue + + if step["action"] == RepoActionStep.WRITE_CHANGELOGS: + continue + + releasetags_2_steps[curr_release_tag].append(step) + + if step["action"] == RepoActionStep.RELEASE: + try: + curr_release_tag = next(next_release_tag_gen) + releasetags_2_steps[curr_release_tag] = [] + except StopIteration: + curr_release_tag = "Unreleased" + releasetags_2_steps[curr_release_tag] = [] + + if ( + "Unreleased" in releasetags_2_steps + and not releasetags_2_steps["Unreleased"] + ): + del releasetags_2_steps["Unreleased"] + + return releasetags_2_steps + + return _split_repo_actions_by_release_tags + + @pytest.fixture(scope="session") def simulate_default_changelog_creation( # noqa: C901 default_md_changelog_insertion_flag: str, From 0c35fd2b0c118c76b8dcdda83491b7be101c40dc Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 6 Dec 2024 23:28:12 -0700 Subject: [PATCH 08/14] test(cmd-version): enable git flow repo rebuild w/ psr test cases --- .../e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py | 1 - .../cmd_version/bump_version/git_flow/test_repo_2_channels.py | 1 - .../cmd_version/bump_version/git_flow/test_repo_3_channels.py | 1 - .../cmd_version/bump_version/git_flow/test_repo_4_channels.py | 1 - 4 files changed, 4 deletions(-) diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py index 336de7f59..281b97e71 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py @@ -45,7 +45,6 @@ ) -@pytest.mark.skip("Currently failing due to git flow releasing bug") @pytest.mark.parametrize( "repo_fixture_name", [ diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py index 5ee8c2798..e61b640b6 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py @@ -45,7 +45,6 @@ ) -@pytest.mark.skip("Currently failing due to git flow releasing bug") @pytest.mark.parametrize( "repo_fixture_name", [ diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py index 2c7c307e9..e815089f0 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py @@ -46,7 +46,6 @@ ) -@pytest.mark.skip("Currently failing due to git flow releasing bug") @pytest.mark.parametrize( "repo_fixture_name", [ diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py index d6e644b3d..d701e50ef 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py @@ -45,7 +45,6 @@ ) -@pytest.mark.skip("Currently failing due to git flow releasing bug") @pytest.mark.parametrize( "repo_fixture_name", [ From fe0abab1c41b8fafe6ddd7e14159ed7a981609c2 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 23 Mar 2024 11:40:39 -0400 Subject: [PATCH 09/14] fix(cmd-version): handle multiple prerelease token variants properly In the case where there are alpha and beta releases, we must only consider the previous beta release even if alpha releases exist due to merging into beta release only branches which have no changes considerable changes from alphas but must be marked otherwise. Resolves: #789 --- src/semantic_release/version/algorithm.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index 2e1022daa..2e6c92828 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -389,6 +389,9 @@ def next_version( # bump to produce. However if we're doing a prerelease, we can # include prereleases here to potentially consider a smaller portion # of history (from a prerelease since the last full release, onwards) + # Since there are different types of prereleases, only consider the + # prereleases that have the same prerelease token as the current branch's + # translator configuration. # Note that a side-effect of this is, if at some point the configuration # for a particular branch pattern changes w.r.t. prerelease=True/False, @@ -397,7 +400,10 @@ def next_version( tag_sha_2_version_lookup = { tag.commit.hexsha: (tag, version) for tag, version in all_git_tags_as_versions - if prerelease or not version.is_prerelease + if ( + (prerelease and version.prerelease_token == translator.prerelease_token) + or not version.is_prerelease + ) } # N.B. these should be sorted so long as we iterate the commits in reverse order From a8ff28a4d7711eee5aa4347fc40d671949dd687b Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 8 Dec 2024 15:57:40 -0700 Subject: [PATCH 10/14] fix(cmd-version): fix algorithm to consolidate commit msgs across merged branches --- src/semantic_release/version/algorithm.py | 175 +++++++++++++++------- 1 file changed, 122 insertions(+), 53 deletions(-) diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index 2e6c92828..c7a0e4b78 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import logging from queue import Queue from typing import TYPE_CHECKING, Iterable @@ -11,6 +12,8 @@ from semantic_release.version.version import Version if TYPE_CHECKING: # pragma: no cover + from typing import Sequence + from git.objects.blob import Blob from git.objects.commit import Commit from git.objects.tag import TagObject @@ -25,6 +28,7 @@ ) from semantic_release.version.translator import VersionTranslator + log = logging.getLogger(__name__) @@ -133,6 +137,84 @@ def bfs(start_commit: Commit | TagObject | Blob | Tree) -> Version | None: return latest_version +def _traverse_graph_4_commits_since_release( + head_commit: Commit, + latest_release_tag_str: str, +) -> Sequence[Commit]: + """ + Run a breadth-first search through the given `merge_base`'s parents, + looking for the most recent version corresponding to a commit in the + `merge_base`'s parents' history. If no commits in the history correspond + to a released version, return None + """ + + # Step 3. Latest full release version within the history of the current branch + # Breadth-first search the merge-base and its parent commits for one which matches + # the tag of the latest full release tag in history + def bfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: + # Derived from Geeks for Geeks + # https://www.geeksforgeeks.org/python-program-for-breadth-first-search-or-bfs-for-a-graph/?ref=lbp + + # Create a queue for BFS + q: Queue[Commit] = Queue() + + # Create a set to store visited graph nodes (commit objects in this case) + visited: set[Commit] = set() + + # Initialize the result + commits: list[Commit] = [] + + if start_commit in stop_nodes: + log.debug("start commit %s is a stop node", start_commit.hexsha[:7]) + return commits + + # Add the source node in the queue to start the search + q.put(start_commit) + + # Traverse the git history capturing each commit found before it reaches a stop node + while not q.empty(): + if (node := q.get()) in visited: + continue + + visited.add(node) + commits.append(node) + + # Add all parent commits to the queue if they haven't been visited (read parents in reverse order) + # as the left side is generally the merged into branch + for parent in node.parents[::-1]: + if parent in visited: + log.debug("parent commit %s already visited", node.hexsha[:7]) + continue + + if parent in stop_nodes: + log.debug("parent commit %s is a stop node", node.hexsha[:7]) + continue + + log.debug("queuing parent commit %s", parent.hexsha[:7]) + q.put(parent) + + return commits + + # Run a Breadth First Search to find all the commits since the last release + commits_since_last_release = bfs( + start_commit=head_commit, + stop_nodes=set( + head_commit.repo.iter_commits(latest_release_tag_str) + if latest_release_tag_str + else [] + ), + ) + + log_msg = ( + f"Found {len(commits_since_last_release)} commits since the last release!" + if len(commits_since_last_release) > 0 + else "No commits found since the last release!" + ) + log.info(log_msg) + + return commits_since_last_release + + def _increment_version( latest_version: Version, latest_full_version: Version, @@ -309,22 +391,19 @@ def next_version( # Conditional log message to inform what was chosen as the comparison point # to find the merge base of the current branch with the latest full release - log_msg = ( - str.join( - ", ", + log_msg = str.join( + ", ", + ( [ "No full releases have been made yet", f"the default version to use is {latest_full_release_version}", - ], - ) - if latest_full_release_tag is None - else str.join( - ", ", - [ + ] + if latest_full_release_tag is None + else [ f"The last full release was {latest_full_release_version}", - f"tagged as {latest_full_release_tag!r}", - ], - ) + f"tagged as {latest_full_release_tag.path}", + ] + ), ) log.info(log_msg) @@ -366,15 +445,17 @@ def next_version( latest_full_version_in_history, ) - commits_since_last_full_release = ( - repo.iter_commits() - if latest_full_version_in_history is None - else repo.iter_commits(f"{latest_full_version_in_history.as_tag()}...") + commits_since_last_full_release = _traverse_graph_4_commits_since_release( + head_commit=repo.active_branch.commit, + latest_release_tag_str=( + latest_full_release_version.as_tag() + if latest_full_release_tag is not None + else "" + ), ) # Step 4. Parse each commit since the last release and find any tags that have # been added since then. - parsed_levels: set[LevelBump] = set() latest_version = latest_full_version_in_history or Version( 0, 0, @@ -383,6 +464,24 @@ def next_version( tag_format=translator.tag_format, ) + if prerelease: + with contextlib.suppress(StopIteration): + latest_version = next( + filter( + lambda version: all( + [ + version.finalize_version() >= latest_full_release_version, + version.prerelease_token == translator.prerelease_token, + ] + ), + [ + version + for _, version in all_git_tags_as_versions + if version.is_prerelease + ], + ) + ) + # We only include pre-releases here if doing a prerelease. # If it's not a prerelease, we need to include commits back # to the last full version in consideration for what kind of @@ -397,44 +496,14 @@ def next_version( # for a particular branch pattern changes w.r.t. prerelease=True/False, # the new kind of version will be produced from the commits already # included in a prerelease since the last full release on the branch - tag_sha_2_version_lookup = { - tag.commit.hexsha: (tag, version) - for tag, version in all_git_tags_as_versions - if ( - (prerelease and version.prerelease_token == translator.prerelease_token) - or not version.is_prerelease - ) - } - - # N.B. these should be sorted so long as we iterate the commits in reverse order - for commit in commits_since_last_full_release: - parse_result = commit_parser.parse(commit) - if isinstance(parse_result, ParsedCommit): - log.debug( - "adding %s to the levels identified in commits_since_last_full_release", - parse_result.bump, - ) - parsed_levels.add(parse_result.bump) - - log.debug("checking if commit %s matches any tags", commit.hexsha) - t_v = tag_sha_2_version_lookup.get(commit.hexsha, None) - - if t_v is None: - # If we haven't found the latest prerelease on the branch, - # keep the loop going to look for it - log.debug("no tags correspond to commit %s", commit.hexsha) - continue - # Unpack the tuple - tag, latest_version = t_v - log.debug( - "tag %r (%s) matches commit %s. the latest version is %s", - tag.name, - tag.commit.hexsha, - commit.hexsha, - latest_version, + parsed_levels: set[LevelBump] = { + parsed_result.bump # type: ignore[union-attr] # too complex for type checkers + for parsed_result in filter( + lambda parsed_result: isinstance(parsed_result, ParsedCommit), + map(commit_parser.parse, commits_since_last_full_release), ) - break + } log.debug( "parsed the following distinct levels from the commits since the last release: " From 5fc476376ac172e5477b1bc4e2ec73a9344c5e77 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 14 Dec 2024 17:45:43 -0700 Subject: [PATCH 11/14] perf(cmd-version): refactor version determination algorithm for accuracy & speed --- src/semantic_release/version/algorithm.py | 383 ++++++++++------------ 1 file changed, 168 insertions(+), 215 deletions(-) diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index c7a0e4b78..d63d1ce6d 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -1,15 +1,14 @@ from __future__ import annotations -import contextlib import logging -from queue import Queue +from contextlib import suppress +from queue import LifoQueue, Queue from typing import TYPE_CHECKING, Iterable from semantic_release.commit_parser import ParsedCommit from semantic_release.const import DEFAULT_VERSION from semantic_release.enums import LevelBump, SemanticReleaseLogLevels -from semantic_release.errors import InvalidVersion, MissingMergeBaseError -from semantic_release.version.version import Version +from semantic_release.errors import InternalError, InvalidVersion if TYPE_CHECKING: # pragma: no cover from typing import Sequence @@ -27,9 +26,10 @@ ParserOptions, ) from semantic_release.version.translator import VersionTranslator + from semantic_release.version.version import Version -log = logging.getLogger(__name__) +logger = logging.getLogger(__name__) def tags_and_versions( @@ -48,18 +48,18 @@ def tags_and_versions( try: version = translator.from_tag(tag.name) except (NotImplementedError, InvalidVersion) as e: - log.warning( + logger.warning( "Couldn't parse tag %s as as Version: %s", tag.name, str(e), - exc_info=log.isEnabledFor(logging.DEBUG), + exc_info=logger.isEnabledFor(logging.DEBUG), ) continue if version: ts_and_vs.append((tag, version)) - log.info("found %s previous tags", len(ts_and_vs)) + logger.info("found %s previous tags", len(ts_and_vs)) return sorted(ts_and_vs, reverse=True, key=lambda v: v[1]) @@ -102,11 +102,11 @@ def bfs(start_commit: Commit | TagObject | Blob | Tree) -> Version | None: node = q.get() visited.add(node) - log.debug("checking if commit %s matches any tags", node.hexsha) + logger.debug("checking if commit %s matches any tags", node.hexsha) version = tag_sha_2_version_lookup.get(node.hexsha, None) if version is not None: - log.info( + logger.info( "found latest version in branch history: %r (%s)", str(version), node.hexsha[:7], @@ -114,15 +114,15 @@ def bfs(start_commit: Commit | TagObject | Blob | Tree) -> Version | None: result = version break - log.debug("commit %s doesn't match any tags", node.hexsha) + logger.debug("commit %s doesn't match any tags", node.hexsha) # Add all parent commits to the queue if they haven't been visited for parent in node.parents: if parent in visited: - log.debug("parent commit %s already visited", node.hexsha) + logger.debug("parent commit %s already visited", node.hexsha) continue - log.debug("queuing parent commit %s", parent.hexsha) + logger.debug("queuing parent commit %s", parent.hexsha) q.put(parent) return result @@ -130,23 +130,46 @@ def bfs(start_commit: Commit | TagObject | Blob | Tree) -> Version | None: # Run a Breadth First Search to find the latest version in the history latest_version = bfs(merge_base) if latest_version is not None: - log.info("the latest version in this branch's history is %s", latest_version) + logger.info("the latest version in this branch's history is %s", latest_version) else: - log.info("no version tags found in this branch's history") + logger.info("no version tags found in this branch's history") return latest_version -def _traverse_graph_4_commits_since_release( +def _traverse_graph_for_commits( head_commit: Commit, - latest_release_tag_str: str, + latest_release_tag_str: str = "", ) -> Sequence[Commit]: - """ - Run a breadth-first search through the given `merge_base`'s parents, - looking for the most recent version corresponding to a commit in the - `merge_base`'s parents' history. If no commits in the history correspond - to a released version, return None - """ + # Depth-first search + def dfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: + # Create a stack for DFS + stack: LifoQueue[Commit] = LifoQueue() + + # Create a set to store visited graph nodes (commit objects in this case) + visited: set[Commit] = set() + + # Initialize the result + commits: list[Commit] = [] + + # Add the source node in the queue to start the search + stack.put(start_commit) + + # Traverse the git history capturing each commit found before it reaches a stop node + while not stack.empty(): + if (node := stack.get()) in visited or node in stop_nodes: + continue + + visited.add(node) + commits.append(node) + + # Add all parent commits to the stack from left to right so that the rightmost is popped first + # as the left side is generally the merged into branch + for parent in node.parents: + logger.debug("queuing parent commit %s", parent.hexsha[:7]) + stack.put(parent) + + return commits # Step 3. Latest full release version within the history of the current branch # Breadth-first search the merge-base and its parent commits for one which matches @@ -165,7 +188,7 @@ def bfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: commits: list[Commit] = [] if start_commit in stop_nodes: - log.debug("start commit %s is a stop node", start_commit.hexsha[:7]) + logger.debug("start commit %s is a stop node", start_commit.hexsha[:7]) return commits # Add the source node in the queue to start the search @@ -183,20 +206,20 @@ def bfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: # as the left side is generally the merged into branch for parent in node.parents[::-1]: if parent in visited: - log.debug("parent commit %s already visited", node.hexsha[:7]) + logger.debug("parent commit %s already visited", node.hexsha[:7]) continue if parent in stop_nodes: - log.debug("parent commit %s is a stop node", node.hexsha[:7]) + logger.debug("parent commit %s is a stop node", node.hexsha[:7]) continue - log.debug("queuing parent commit %s", parent.hexsha[:7]) + logger.debug("queuing parent commit %s", parent.hexsha[:7]) q.put(parent) return commits - # Run a Breadth First Search to find all the commits since the last release - commits_since_last_release = bfs( + # Run a Depth First Search to find all the commits since the last release + commits_since_last_release = dfs( start_commit=head_commit, stop_nodes=set( head_commit.repo.iter_commits(latest_release_tag_str) @@ -210,7 +233,7 @@ def bfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: if len(commits_since_last_release) > 0 else "No commits found since the last release!" ) - log.info(log_msg) + logger.info(log_msg) return commits_since_last_release @@ -218,7 +241,6 @@ def bfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: def _increment_version( latest_version: Version, latest_full_version: Version, - latest_full_version_in_history: Version, level_bump: LevelBump, prerelease: bool, prerelease_token: str, @@ -240,7 +262,7 @@ def _increment_version( is in this branch's history. """ local_vars = list(locals().items()) - log.log( + logger.log( SemanticReleaseLogLevels.SILLY, "_increment_version: %s", str.join(", ", [f"{k} = {v}" for k, v in local_vars]), @@ -251,7 +273,7 @@ def _increment_version( if not allow_zero_version: # Set up default version to be 1.0.0 if currently 0.x.x which means a commented # breaking change is not required to bump to 1.0.0 - log.debug( + logger.debug( "Bumping major version as 0.x.x versions are disabled because of allow_zero_version=False" ) level_bump = LevelBump.MAJOR @@ -261,45 +283,43 @@ def _increment_version( # breaking changes should increment the minor digit # Correspondingly, we reduce the level that we increment the # version by. - log.debug( + logger.debug( "reducing version increment due to 0. version and major_on_zero=False" ) level_bump = min(level_bump, LevelBump.MINOR) + # Determine the difference between the latest version and the latest full release + diff_with_last_released_version = latest_version - latest_full_version + logger.debug( + "diff between the latest version %s and the latest full release version %s is: %s", + latest_version, + latest_full_version, + diff_with_last_released_version, + ) + + # Handle prerelease version bumps if prerelease: - log.debug("prerelease=true") - target_final_version = latest_full_version.finalize_version() - diff_with_last_released_version = ( - latest_version - latest_full_version_in_history - ) - log.debug( - "diff between the latest version %s and the latest full release version %s " - "is: %s", - latest_version, - latest_full_version_in_history, - diff_with_last_released_version, - ) # 6a i) if the level_bump > the level bump introduced by any prerelease tag # before e.g. 1.2.4-rc.3 -> 1.3.0-rc.1 if level_bump > diff_with_last_released_version: - log.debug( - "this release has a greater bump than any change since the last full " - "release, %s", - latest_full_version_in_history, + logger.debug( + "this release has a greater bump than any change since the last full release, %s", + latest_full_version, ) - return target_final_version.bump(level_bump).to_prerelease( - token=prerelease_token + return ( + latest_full_version.finalize_version() + .bump(level_bump) + .to_prerelease(token=prerelease_token) ) # 6a ii) if level_bump <= the level bump introduced by prerelease tag - log.debug( - "there has already been at least a %s release since the last full " - "release %s", + logger.debug( + "there has already been at least a %s release since the last full release %s", level_bump, - latest_full_version_in_history, + latest_full_version, ) - log.debug("this release will increment the prerelease revision") + logger.debug("this release will increment the prerelease revision") return latest_version.to_prerelease( token=prerelease_token, revision=( @@ -313,36 +333,25 @@ def _increment_version( # NOTE: These can actually be condensed down to the single line # 6b. i) if there's been a prerelease if latest_version.is_prerelease: - log.debug( + logger.debug( "prerelease=false and the latest version %s is a prerelease", latest_version ) - diff_with_last_released_version = ( - latest_version - latest_full_version_in_history - ) - log.debug( - "diff between the latest version %s and the latest full release version %s " - "is: %s", - latest_version, - latest_full_version_in_history, - diff_with_last_released_version, - ) if level_bump > diff_with_last_released_version: - log.debug( - "this release has a greater bump than any change since the last full " - "release, %s", - latest_full_version_in_history, + logger.debug( + "this release has a greater bump than any change since the last full release, %s", + latest_full_version, ) return latest_version.bump(level_bump).finalize_version() - log.debug( - "there has already been at least a %s release since the last full " - "release %s", + + logger.debug( + "there has already been at least a %s release since the last full release %s", level_bump, - latest_full_version_in_history, + latest_full_version, ) return latest_version.finalize_version() # 6b. ii) If there's been no prerelease - log.debug( + logger.debug( "prerelease=false and %s is not a prerelease; bumping with a %s release", latest_version, level_bump, @@ -362,174 +371,118 @@ def next_version( Evaluate the history within `repo`, and based on the tags and commits in the repo history, identify the next semantic version that should be applied to a release """ - # Step 1. All tags, sorted descending by semver ordering rules - all_git_tags_as_versions = tags_and_versions(repo.tags, translator) - all_full_release_tags_and_versions = list( - filter(lambda t_v: not t_v[1].is_prerelease, all_git_tags_as_versions) - ) - log.info( - "Found %s full releases (excluding prereleases)", - len(all_full_release_tags_and_versions), - ) - # Default initial version - latest_full_release_tag, latest_full_release_version = next( - iter(all_full_release_tags_and_versions), - (None, translator.from_string(DEFAULT_VERSION)), + # Since the translator is configured by the user, we can't guarantee that it will + # be able to parse the default version. So we first cast it to a tag using the default + # value and the users configured tag format, then parse it back to a version object + default_initial_version = translator.from_tag( + translator.str_to_tag(DEFAULT_VERSION) ) + if default_initial_version is None: + raise InternalError( + "Translator was unable to parse the embedded default version" + ) - # we can safely scan the extra commits on this - # branch if it's never been released, but we have no other - # guarantees that other branches exist - # Note the merge_base might be on our current branch, it's not - # necessarily the merge base of the current branch with `main` - other_ref = ( - repo.active_branch - if latest_full_release_tag is None - else latest_full_release_tag.name - ) + # Step 1. All tags, sorted descending by semver ordering rules + all_git_tags_as_versions = tags_and_versions(repo.tags, translator) + + # Retrieve all commit hashes (regardless of merges) in the current branch's history from repo origin + commit_hash_set = { + commit.hexsha + for commit in _traverse_graph_for_commits(head_commit=repo.active_branch.commit) + } - # Conditional log message to inform what was chosen as the comparison point - # to find the merge base of the current branch with the latest full release - log_msg = str.join( - ", ", - ( - [ - "No full releases have been made yet", - f"the default version to use is {latest_full_release_version}", - ] - if latest_full_release_tag is None - else [ - f"The last full release was {latest_full_release_version}", - f"tagged as {latest_full_release_tag.path}", - ] + # Filter all releases that are not found in the current branch's history + historic_versions: list[Version] = [] + for tag, version in all_git_tags_as_versions: + # TODO: move this to tags_and_versions() function? + # Ignore the error that is raised when tag points to a Blob or Tree object rather + # than a commit object (tags that point to tags that then point to commits are resolved automatically) + with suppress(ValueError): + if tag.commit.hexsha in commit_hash_set: + historic_versions.append(version) + + # Step 2. Get the latest final release version in the history of the current branch + # or fallback to the default 0.0.0 starting version value if none are found + latest_full_release_version = next( + filter( + lambda version: not version.is_prerelease, + historic_versions, ), + default_initial_version, ) - log.info(log_msg) - merge_bases = repo.merge_base(other_ref, repo.active_branch) - - if len(merge_bases) < 1: - raise MissingMergeBaseError( - f"Unable to find merge-base between {other_ref} and {repo.active_branch.name}" - ) - - if len(merge_bases) > 1: - raise NotImplementedError( - str.join( - " ", - [ - "This branch has more than one merge-base with the", - "latest version, which is not yet supported", - ], - ) - ) - - merge_base = merge_bases[0] + logger.info( + f"The last full version in this branch's history was {latest_full_release_version}" + if latest_full_release_version != default_initial_version + else "No full releases found in this branch's history" + ) - if merge_base is None: - str_tag_name = ( - "None" if latest_full_release_tag is None else latest_full_release_tag.name - ) - raise ValueError( - f"The merge_base found by merge_base({str_tag_name}, {repo.active_branch}) " - "is None" + # Step 3. Determine the latest release version in the history of the current branch + # If we the desired result is a prerelease, we must determine if there was any previous + # prerelease in the history of the current branch beyond the latest_full_release_version. + # Important to note that, we only consider prereleases that are of the same prerelease token + # as the basis of incrementing the prerelease revision. + # If we are not looking for a prerelease, this is the same as the last full release. + latest_version = ( + latest_full_release_version + if not prerelease + else next( + filter( + lambda version: all( + [ + version.is_prerelease, + version.prerelease_token == translator.prerelease_token, + version >= latest_full_release_version, + ] + ), + historic_versions, + ), + latest_full_release_version, # default ) - - latest_full_version_in_history = _bfs_for_latest_version_in_history( - merge_base=merge_base, - full_release_tags_and_versions=all_full_release_tags_and_versions, - ) - log.info( - "The last full version in this branch's history was %s", - latest_full_version_in_history, ) - commits_since_last_full_release = _traverse_graph_4_commits_since_release( + logger.info("The latest release in this branch's history was %s", latest_version) + + # Step 4. Walk the git tree to find all commits that have been made since the last release + commits_since_last_release = _traverse_graph_for_commits( head_commit=repo.active_branch.commit, latest_release_tag_str=( - latest_full_release_version.as_tag() - if latest_full_release_tag is not None - else "" + # NOTE: the default_initial_version should not actually exist on the repository (ie v0.0.0) + # so we provide an empty tag string when there are no tags on the repository yet + latest_version.as_tag() if latest_version != default_initial_version else "" ), ) - # Step 4. Parse each commit since the last release and find any tags that have - # been added since then. - latest_version = latest_full_version_in_history or Version( - 0, - 0, - 0, - prerelease_token=translator.prerelease_token, - tag_format=translator.tag_format, - ) - - if prerelease: - with contextlib.suppress(StopIteration): - latest_version = next( - filter( - lambda version: all( - [ - version.finalize_version() >= latest_full_release_version, - version.prerelease_token == translator.prerelease_token, - ] - ), - [ - version - for _, version in all_git_tags_as_versions - if version.is_prerelease - ], - ) - ) - - # We only include pre-releases here if doing a prerelease. - # If it's not a prerelease, we need to include commits back - # to the last full version in consideration for what kind of - # bump to produce. However if we're doing a prerelease, we can - # include prereleases here to potentially consider a smaller portion - # of history (from a prerelease since the last full release, onwards) - # Since there are different types of prereleases, only consider the - # prereleases that have the same prerelease token as the current branch's - # translator configuration. - - # Note that a side-effect of this is, if at some point the configuration - # for a particular branch pattern changes w.r.t. prerelease=True/False, - # the new kind of version will be produced from the commits already - # included in a prerelease since the last full release on the branch - + # Step 5. Parse the commits to determine the bump level that should be applied parsed_levels: set[LevelBump] = { parsed_result.bump # type: ignore[union-attr] # too complex for type checkers for parsed_result in filter( lambda parsed_result: isinstance(parsed_result, ParsedCommit), - map(commit_parser.parse, commits_since_last_full_release), + map(commit_parser.parse, commits_since_last_release), ) } - log.debug( - "parsed the following distinct levels from the commits since the last release: " - "%s", + logger.debug( + "parsed the following distinct levels from the commits since the last release: %s", parsed_levels, ) + level_bump = max(parsed_levels, default=LevelBump.NO_RELEASE) - log.info("The type of the next release release is: %s", level_bump) - if level_bump is LevelBump.NO_RELEASE: # noqa: SIM102 - if latest_version.major != 0 or allow_zero_version: - log.info("No release will be made") - return latest_version + logger.info("The type of the next release release is: %s", level_bump) + + if all( + [ + level_bump is LevelBump.NO_RELEASE, + latest_version.major != 0 or allow_zero_version, + ] + ): + logger.info("No release will be made") + return latest_version return _increment_version( latest_version=latest_version, latest_full_version=latest_full_release_version, - latest_full_version_in_history=( - latest_full_version_in_history - or Version( - 0, - 0, - 0, - prerelease_token=translator.prerelease_token, - tag_format=translator.tag_format, - ) - ), level_bump=level_bump, prerelease=prerelease, prerelease_token=translator.prerelease_token, From ad4cf9dc71ea907d31e73afac0347d30802ff145 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 14 Dec 2024 17:46:29 -0700 Subject: [PATCH 12/14] test(algorithm): refactor test to match new function signature --- .../version/test_algorithm.py | 251 +++++------------- 1 file changed, 67 insertions(+), 184 deletions(-) diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index c48d89e22..836022811 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -188,197 +188,81 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( @pytest.mark.parametrize( - "latest_version, latest_full_version, latest_full_version_in_history, level_bump, " - "prerelease, prerelease_token, expected_version", + str.join( + ", ", + [ + "latest_version", + "latest_full_version", + "level_bump", + "prerelease", + "prerelease_token", + "expected_version", + ], + ), [ # NOTE: level_bump != LevelBump.NO_RELEASE, we return early in the # algorithm to discount this case - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PRERELEASE_REVISION, - False, - "rc", - "1.0.0-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PRERELEASE_REVISION, - True, - "rc", - "1.0.0-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PATCH, - False, - "rc", - "1.0.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.PATCH, - True, - "rc", - "1.0.1-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MINOR, - False, - "rc", - "1.1.0", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MINOR, - True, - "rc", - "1.1.0-rc.1", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MAJOR, - False, - "rc", - "2.0.0", - ), - ( - "1.0.0", - "1.0.0", - "1.0.0", - LevelBump.MAJOR, - True, - "rc", - "2.0.0-rc.1", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.PATCH, - False, - "rc", - "1.2.4", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.PATCH, - True, - "rc", - "1.2.4-rc.2", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MINOR, - False, - "rc", - "1.3.0", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MINOR, - True, - "rc", - "1.3.0-rc.1", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MAJOR, - False, - "rc", - "2.0.0", - ), - ( - "1.2.4-rc.1", - "1.2.0", - "1.2.3", - LevelBump.MAJOR, - True, - "rc", - "2.0.0-rc.1", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.PATCH, - False, - "rc", - "2.0.0", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.PATCH, - True, - "rc", - "2.0.0-rc.2", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MINOR, - False, - "rc", - "2.0.0", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MINOR, - True, - "rc", - "2.0.0-rc.2", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MAJOR, - False, - "rc", - "2.0.0", - ), - ( - "2.0.0-rc.1", - "1.22.0", - "1.19.3", - LevelBump.MAJOR, - True, - "rc", - "2.0.0-rc.2", - ), + *[ + ( + "1.0.0", + "1.0.0", + bump_level, + prerelease, + "rc", + expected_version, + ) + for bump_level, prerelease, expected_version in [ + (LevelBump.PRERELEASE_REVISION, False, "1.0.0-rc.1"), + (LevelBump.PRERELEASE_REVISION, True, "1.0.0-rc.1"), + (LevelBump.PATCH, False, "1.0.1"), + (LevelBump.PATCH, True, "1.0.1-rc.1"), + (LevelBump.MINOR, False, "1.1.0"), + (LevelBump.MINOR, True, "1.1.0-rc.1"), + (LevelBump.MAJOR, False, "2.0.0"), + (LevelBump.MAJOR, True, "2.0.0-rc.1"), + ] + ], + *[ + ( + "1.2.4-rc.1", + "1.2.3", + bump_level, + prerelease, + "rc", + expected_version, + ) + for bump_level, prerelease, expected_version in [ + (LevelBump.PATCH, False, "1.2.4"), + (LevelBump.PATCH, True, "1.2.4-rc.2"), + (LevelBump.MINOR, False, "1.3.0"), + (LevelBump.MINOR, True, "1.3.0-rc.1"), + (LevelBump.MAJOR, False, "2.0.0"), + (LevelBump.MAJOR, True, "2.0.0-rc.1"), + ] + ], + *[ + ( + "2.0.0-rc.1", + "1.22.0", + bump_level, + prerelease, + "rc", + expected_version, + ) + for bump_level, prerelease, expected_version in [ + (LevelBump.PATCH, False, "2.0.0"), + (LevelBump.PATCH, True, "2.0.0-rc.2"), + (LevelBump.MINOR, False, "2.0.0"), + (LevelBump.MINOR, True, "2.0.0-rc.2"), + (LevelBump.MAJOR, False, "2.0.0"), + (LevelBump.MAJOR, True, "2.0.0-rc.2"), + ] + ], ], ) def test_increment_version_no_major_on_zero( latest_version: str, latest_full_version: str, - latest_full_version_in_history: str, level_bump: LevelBump, prerelease: bool, prerelease_token: str, @@ -387,7 +271,6 @@ def test_increment_version_no_major_on_zero( actual = _increment_version( latest_version=Version.parse(latest_version), latest_full_version=Version.parse(latest_full_version), - latest_full_version_in_history=Version.parse(latest_full_version_in_history), level_bump=level_bump, prerelease=prerelease, prerelease_token=prerelease_token, From a5de6db055d77c5c8523f07deac223d74466c094 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 14 Dec 2024 18:29:16 -0700 Subject: [PATCH 13/14] style(algorithm): drop unused functions & imports --- src/semantic_release/version/algorithm.py | 126 +--------------------- 1 file changed, 1 insertion(+), 125 deletions(-) diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index d63d1ce6d..10ca24bd5 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -2,7 +2,7 @@ import logging from contextlib import suppress -from queue import LifoQueue, Queue +from queue import LifoQueue from typing import TYPE_CHECKING, Iterable from semantic_release.commit_parser import ParsedCommit @@ -13,10 +13,7 @@ if TYPE_CHECKING: # pragma: no cover from typing import Sequence - from git.objects.blob import Blob from git.objects.commit import Commit - from git.objects.tag import TagObject - from git.objects.tree import Tree from git.refs.tag import Tag from git.repo.base import Repo @@ -63,80 +60,6 @@ def tags_and_versions( return sorted(ts_and_vs, reverse=True, key=lambda v: v[1]) -def _bfs_for_latest_version_in_history( - merge_base: Commit | TagObject | Blob | Tree, - full_release_tags_and_versions: list[tuple[Tag, Version]], -) -> Version | None: - """ - Run a breadth-first search through the given `merge_base`'s parents, - looking for the most recent version corresponding to a commit in the - `merge_base`'s parents' history. If no commits in the history correspond - to a released version, return None - """ - tag_sha_2_version_lookup = { - tag.commit.hexsha: version for tag, version in full_release_tags_and_versions - } - - # Step 3. Latest full release version within the history of the current branch - # Breadth-first search the merge-base and its parent commits for one which matches - # the tag of the latest full release tag in history - def bfs(start_commit: Commit | TagObject | Blob | Tree) -> Version | None: - # Derived from Geeks for Geeks - # https://www.geeksforgeeks.org/python-program-for-breadth-first-search-or-bfs-for-a-graph/?ref=lbp - - # Create a queue for BFS - q: Queue[Commit | TagObject | Blob | Tree] = Queue() - - # Create a set to store visited graph nodes (commit objects in this case) - visited: set[Commit | TagObject | Blob | Tree] = set() - - # Add the source node in the queue & mark as visited to start the search - q.put(start_commit) - visited.add(start_commit) - - # Initialize the result to None - result = None - - # Traverse the git history until it finds a version tag if one exists - while not q.empty(): - node = q.get() - visited.add(node) - - logger.debug("checking if commit %s matches any tags", node.hexsha) - version = tag_sha_2_version_lookup.get(node.hexsha, None) - - if version is not None: - logger.info( - "found latest version in branch history: %r (%s)", - str(version), - node.hexsha[:7], - ) - result = version - break - - logger.debug("commit %s doesn't match any tags", node.hexsha) - - # Add all parent commits to the queue if they haven't been visited - for parent in node.parents: - if parent in visited: - logger.debug("parent commit %s already visited", node.hexsha) - continue - - logger.debug("queuing parent commit %s", parent.hexsha) - q.put(parent) - - return result - - # Run a Breadth First Search to find the latest version in the history - latest_version = bfs(merge_base) - if latest_version is not None: - logger.info("the latest version in this branch's history is %s", latest_version) - else: - logger.info("no version tags found in this branch's history") - - return latest_version - - def _traverse_graph_for_commits( head_commit: Commit, latest_release_tag_str: str = "", @@ -171,53 +94,6 @@ def dfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: return commits - # Step 3. Latest full release version within the history of the current branch - # Breadth-first search the merge-base and its parent commits for one which matches - # the tag of the latest full release tag in history - def bfs(start_commit: Commit, stop_nodes: set[Commit]) -> Sequence[Commit]: - # Derived from Geeks for Geeks - # https://www.geeksforgeeks.org/python-program-for-breadth-first-search-or-bfs-for-a-graph/?ref=lbp - - # Create a queue for BFS - q: Queue[Commit] = Queue() - - # Create a set to store visited graph nodes (commit objects in this case) - visited: set[Commit] = set() - - # Initialize the result - commits: list[Commit] = [] - - if start_commit in stop_nodes: - logger.debug("start commit %s is a stop node", start_commit.hexsha[:7]) - return commits - - # Add the source node in the queue to start the search - q.put(start_commit) - - # Traverse the git history capturing each commit found before it reaches a stop node - while not q.empty(): - if (node := q.get()) in visited: - continue - - visited.add(node) - commits.append(node) - - # Add all parent commits to the queue if they haven't been visited (read parents in reverse order) - # as the left side is generally the merged into branch - for parent in node.parents[::-1]: - if parent in visited: - logger.debug("parent commit %s already visited", node.hexsha[:7]) - continue - - if parent in stop_nodes: - logger.debug("parent commit %s is a stop node", node.hexsha[:7]) - continue - - logger.debug("queuing parent commit %s", parent.hexsha[:7]) - q.put(parent) - - return commits - # Run a Depth First Search to find all the commits since the last release commits_since_last_release = dfs( start_commit=head_commit, From efae6c32bcb8d50bdadbd4bcd7bc84ea6121ecda Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 14 Dec 2024 18:30:13 -0700 Subject: [PATCH 14/14] test(algorithm): adapt test case for new dfs commit traversal implementation --- .../version/test_algorithm.py | 72 +++++++++++++------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index 836022811..431f7796b 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -1,32 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest import mock + import pytest from git import Commit, Repo, TagReference from semantic_release.enums import LevelBump from semantic_release.version.algorithm import ( - _bfs_for_latest_version_in_history, _increment_version, + _traverse_graph_for_commits, tags_and_versions, ) from semantic_release.version.translator import VersionTranslator from semantic_release.version.version import Version +from tests.fixtures.repos import repo_w_initial_commit + +if TYPE_CHECKING: + from typing import Sequence -def test_bfs_for_latest_version_in_history(): + +@pytest.mark.usefixtures(repo_w_initial_commit.__name__) +def test_traverse_graph_for_commits(): # Setup fake git graph """ - * merge commit 6 (start) + * merge commit 6 (start) [3636363] |\ - | * commit 5 - | * commit 4 + | * commit 5 [3535353] + | * commit 4 [3434343] |/ - * commit 3 - * commit 2 - * commit 1 - * v1.0.0 + * commit 3 [3333333] + * commit 2 [3232323] + * commit 1 [3131313] + * v1.0.0 [3030303] """ repo = Repo() - expected_version = Version.parse("1.0.0") - v1_commit = Commit(repo, binsha=b"0" * 20) + v1_commit = Commit(repo, binsha=b"0" * 20, parents=[]) class TagReferenceOverride(TagReference): commit = v1_commit # mocking the commit property @@ -61,16 +71,36 @@ class TagReferenceOverride(TagReference): ], ) + commit_1 = trunk.parents[0].parents[0] + commit_2 = trunk.parents[0] + commit_3 = trunk + commit_4 = start_commit.parents[1].parents[0] + commit_5 = start_commit.parents[1] + commit_6 = start_commit + + expected_commit_order = [ + commit_6.hexsha, + commit_5.hexsha, + commit_4.hexsha, + commit_3.hexsha, + commit_2.hexsha, + commit_1.hexsha, + ] + # Execute - actual = _bfs_for_latest_version_in_history( - start_commit, - [ - (v1_tag, expected_version), - ], - ) + with mock.patch.object( + repo, repo.iter_commits.__name__, return_value=iter([v1_commit]) + ): + actual_commit_order = [ + commit.hexsha + for commit in _traverse_graph_for_commits( + head_commit=start_commit, + latest_release_tag_str=v1_tag.name, + ) + ] # Verify - assert expected_version == (actual or "") + assert expected_commit_order == actual_commit_order @pytest.mark.parametrize( @@ -114,7 +144,7 @@ class TagReferenceOverride(TagReference): ), ], ) -def test_sorted_repo_tags_and_versions(tags, sorted_tags): +def test_sorted_repo_tags_and_versions(tags: list[str], sorted_tags: list[str]): repo = Repo() translator = VersionTranslator() tagrefs = [repo.tag(tag) for tag in tags] @@ -178,7 +208,9 @@ def test_sorted_repo_tags_and_versions(tags, sorted_tags): ], ) def test_tags_and_versions_ignores_invalid_tags_as_versions( - tag_format, invalid_tags, valid_tags + tag_format: str, + invalid_tags: Sequence[str], + valid_tags: Sequence[str], ): repo = Repo() translator = VersionTranslator(tag_format=tag_format)