diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index 762007b4..0ef0b691 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -23,20 +23,21 @@ import sys from typing import Optional +import semver + from github import Github from github.GitRelease import GitRelease from github.Repository import Repository +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.builder import ReleaseNotesBuilder from release_notes_generator.model.custom_chapters import CustomChapters from release_notes_generator.model.record import Record -from release_notes_generator.builder import ReleaseNotesBuilder from release_notes_generator.record.record_factory import RecordFactory -from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.utils.constants import ISSUE_STATE_ALL - from release_notes_generator.utils.decorators import safe_call_decorator -from release_notes_generator.utils.utils import get_change_url from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter +from release_notes_generator.utils.utils import get_change_url logger = logging.getLogger(__name__) @@ -134,6 +135,7 @@ def get_latest_release(self, repo: Repository) -> Optional[GitRelease]: @param repo: The repository to get the latest release from. @return: The latest release of the repository, or None if no releases are found. """ + # check if from-tag name is defined if ActionInputs.is_from_tag_name_defined(): logger.info("Getting latest release by from-tag name %s", ActionInputs.get_tag_name()) rls: GitRelease = self._safe_call(repo.get_release)(ActionInputs.get_from_tag_name()) @@ -143,8 +145,9 @@ def get_latest_release(self, repo: Repository) -> Optional[GitRelease]: sys.exit(1) else: - logger.info("Getting latest release by time.") - rls: GitRelease = self._safe_call(repo.get_latest_release)() + logger.info("Getting latest release by semantic ordering (could not be the last one by time).") + gh_releases: list = list(self._safe_call(repo.get_releases)()) + rls: GitRelease = self.__get_latest_semantic_release(gh_releases) if rls is None: logger.info("Latest release not found for %s. 1st release for repository!", repo.full_name) @@ -158,3 +161,25 @@ def get_latest_release(self, repo: Repository) -> Optional[GitRelease]: ) return rls + + def __get_latest_semantic_release(self, releases) -> Optional[GitRelease]: + published_releases = [release for release in releases if not release.draft and not release.prerelease] + latest_version: Optional[semver.Version] = None + rls: Optional[GitRelease] = None + + for release in published_releases: + try: + version_str = release.tag_name.lstrip("v") + current_version: Optional[semver.Version] = semver.VersionInfo.parse(version_str) + except ValueError: + logger.debug("Skipping invalid value of version tag: %s", release.tag_name) + continue + except TypeError: + logger.debug("Skipping invalid type of version tag: %s", release.tag_name) + continue + + if latest_version is None or current_version > latest_version: + latest_version = current_version + rls = release + + return rls diff --git a/requirements.txt b/requirements.txt index a2a26455..86345820 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ PyGithub==1.59.0 pylint==3.2.6 requests==2.31.0 black==24.8.0 +semver==3.0.2 diff --git a/tests/conftest.py b/tests/conftest.py index d8353e6c..87777346 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ import pytest from github import Github +from github.GitRelease import GitRelease from github.Issue import Issue from github.PullRequest import PullRequest from github.Rate import Rate @@ -83,11 +84,24 @@ def mock_repo(mocker): # Fixtures for GitHub Release(s) @pytest.fixture def mock_git_release(mocker): - release = mocker.Mock() + release = mocker.Mock(spec=GitRelease) release.tag_name = "v1.0.0" return release +@pytest.fixture +def mock_git_releases(mocker): + release_1 = mocker.Mock(spec=GitRelease) + release_1.tag_name = "v1.0.0" + release_1.draft = False + release_1.prerelease = False + release_2 = mocker.Mock(spec=GitRelease) + release_2.tag_name = "v2.0.0" + release_2.draft = False + release_2.prerelease = False + return [release_1, release_2] + + @pytest.fixture def rate_limiter(mocker, request): mock_github_client = mocker.Mock(spec=Github) diff --git a/tests/test_release_notes_generator.py b/tests/test_release_notes_generator.py index 82c784a5..4b39c8a6 100644 --- a/tests/test_release_notes_generator.py +++ b/tests/test_release_notes_generator.py @@ -66,7 +66,7 @@ def test_generate_release_notes_latest_release_not_found( mock_pull_closed_with_rls_notes_101.merged_at = mock_repo.created_at + timedelta(days=2) mock_pull_closed_with_rls_notes_102.merged_at = mock_repo.created_at + timedelta(days=7) - github_mock.get_repo().get_latest_release.return_value = None + mocker.patch("release_notes_generator.generator.ReleaseNotesGenerator.get_latest_release", return_value=None) mock_rate_limit = mocker.Mock() mock_rate_limit.core.remaining = 1000 @@ -107,9 +107,9 @@ def test_generate_release_notes_latest_release_found_by_created_at( mock_pull_closed_with_rls_notes_101.merged_at = mock_repo.created_at + timedelta(days=2) mock_pull_closed_with_rls_notes_102.merged_at = mock_repo.created_at + timedelta(days=7) - github_mock.get_repo().get_latest_release.return_value = mock_git_release mock_git_release.created_at = mock_repo.created_at + timedelta(days=5) mock_git_release.published_at = mock_repo.created_at + timedelta(days=5) + mocker.patch("release_notes_generator.generator.ReleaseNotesGenerator.get_latest_release", return_value=mock_git_release) mock_rate_limit = mocker.Mock() mock_rate_limit.core.remaining = 1000 @@ -158,6 +158,7 @@ def test_generate_release_notes_latest_release_found_by_published_at( github_mock.get_repo().get_latest_release.return_value = mock_git_release mock_git_release.created_at = mock_repo.created_at + timedelta(days=5) mock_git_release.published_at = mock_repo.created_at + timedelta(days=5) + mocker.patch("release_notes_generator.generator.ReleaseNotesGenerator.get_latest_release", return_value=mock_git_release) mock_rate_limit = mocker.Mock() mock_rate_limit.core.remaining = 1000 @@ -223,3 +224,99 @@ def test_get_latest_release_from_tag_name_defined_release_exists(mocker, mock_re mock_exit.assert_not_called() assert mock_log_info.called_with(1) assert ('Getting latest release by from-tag name %s', None) == mock_log_info.call_args_list[0][0] + + +def test_get_latest_release_from_tag_name_not_defined_no_release(mocker, mock_repo): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.is_from_tag_name_defined", return_value=False) + mock_log_info = mocker.patch("release_notes_generator.generator.logger.info") + + github_mock = mocker.Mock(spec=Github) + github_mock.get_repo.return_value = mock_repo + + mock_repo.get_releases.return_value = [] + + mock_rate_limit = mocker.Mock() + mock_rate_limit.core.remaining = 1000 + github_mock.get_rate_limit.return_value = mock_rate_limit + + release_notes_generator = ReleaseNotesGenerator(github_mock, CustomChapters(print_empty_chapters=True)) + + latest_release = release_notes_generator.get_latest_release(mock_repo) + + assert latest_release is None + assert mock_log_info.called_with(2) + assert ('Getting latest release by semantic ordering (could not be the last one by time).',) == mock_log_info.call_args_list[0][0] + assert ('Latest release not found for %s. 1st release for repository!', 'org/repo') == mock_log_info.call_args_list[1][0] + + +def test_get_latest_release_from_tag_name_not_defined_2_releases(mocker, mock_repo, mock_git_releases): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.is_from_tag_name_defined", return_value=False) + mock_log_info = mocker.patch("release_notes_generator.generator.logger.info") + + github_mock = mocker.Mock(spec=Github) + github_mock.get_repo.return_value = mock_repo + + mock_repo.get_releases.return_value = mock_git_releases + + mock_rate_limit = mocker.Mock() + mock_rate_limit.core.remaining = 1000 + github_mock.get_rate_limit.return_value = mock_rate_limit + + release_notes_generator = ReleaseNotesGenerator(github_mock, CustomChapters(print_empty_chapters=True)) + + latest_release = release_notes_generator.get_latest_release(mock_repo) + + assert latest_release is not None + assert ('Getting latest release by semantic ordering (could not be the last one by time).',) == mock_log_info.call_args_list[0][0] + + +def test_get_latest_release_from_tag_name_not_defined_2_releases_value_error(mocker, mock_repo, mock_git_releases): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.is_from_tag_name_defined", return_value=False) + mock_log_info = mocker.patch("release_notes_generator.generator.logger.info") + mock_log_debug = mocker.patch("release_notes_generator.generator.logger.debug") + + github_mock = mocker.Mock(spec=Github) + github_mock.get_repo.return_value = mock_repo + + mock_repo.get_releases.return_value = mock_git_releases + + mock_rate_limit = mocker.Mock() + mock_rate_limit.core.remaining = 1000 + github_mock.get_rate_limit.return_value = mock_rate_limit + + release_notes_generator = ReleaseNotesGenerator(github_mock, CustomChapters(print_empty_chapters=True)) + mocker.patch("semver.Version.parse", side_effect=ValueError) + + latest_release = release_notes_generator.get_latest_release(mock_repo) + + assert latest_release is None + assert ('Getting latest release by semantic ordering (could not be the last one by time).',) == mock_log_info.call_args_list[0][0] + assert ('Latest release not found for %s. 1st release for repository!', 'org/repo') == mock_log_info.call_args_list[1][0] + assert ('Skipping invalid value of version tag: %s', 'v1.0.0') == mock_log_debug.call_args_list[0][0] + assert ('Skipping invalid value of version tag: %s', 'v2.0.0') == mock_log_debug.call_args_list[1][0] + + +def test_get_latest_release_from_tag_name_not_defined_2_releases_type_error(mocker, mock_repo, mock_git_releases): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.is_from_tag_name_defined", return_value=False) + mock_log_info = mocker.patch("release_notes_generator.generator.logger.info") + mock_log_debug = mocker.patch("release_notes_generator.generator.logger.debug") + + github_mock = mocker.Mock(spec=Github) + github_mock.get_repo.return_value = mock_repo + + mock_repo.get_releases.return_value = mock_git_releases + + mock_rate_limit = mocker.Mock() + mock_rate_limit.core.remaining = 1000 + github_mock.get_rate_limit.return_value = mock_rate_limit + + release_notes_generator = ReleaseNotesGenerator(github_mock, CustomChapters(print_empty_chapters=True)) + mocker.patch("semver.Version.parse", side_effect=TypeError) + + latest_release = release_notes_generator.get_latest_release(mock_repo) + + assert latest_release is None + assert ('Getting latest release by semantic ordering (could not be the last one by time).',) == mock_log_info.call_args_list[0][0] + assert ('Latest release not found for %s. 1st release for repository!', 'org/repo') == mock_log_info.call_args_list[1][0] + assert ('Skipping invalid type of version tag: %s', 'v1.0.0') == mock_log_debug.call_args_list[0][0] + assert ('Skipping invalid type of version tag: %s', 'v2.0.0') == mock_log_debug.call_args_list[1][0]