diff --git a/.github/workflows/release_draft.yml b/.github/workflows/release_draft.yml index 479795fc..5e34f49e 100644 --- a/.github/workflows/release_draft.yml +++ b/.github/workflows/release_draft.yml @@ -21,6 +21,9 @@ on: tag-name: description: 'Name of git tag to be created, and then draft release created. Syntax: "v[0-9]+.[0-9]+.[0-9]+".' required: true + from-tag-name: + description: 'Name of the git tag from which to detect changes from. Default value: latest tag. Syntax: "v[0-9]+.[0-9]+.[0-9]+".' + required: false jobs: release-draft: @@ -37,13 +40,24 @@ jobs: - name: Check format of received tag id: check-version-tag - uses: AbsaOSS/version-tag-check@v0.2.0 + uses: AbsaOSS/version-tag-check@v0.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-repository: ${{ github.repository }} version-tag: ${{ github.event.inputs.tag-name }} + - name: Check format of received from tag + if: ${{ github.event.inputs.from-tag-name }} + id: check-version-from-tag + uses: AbsaOSS/version-tag-check@v0.3.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-repository: ${{ github.repository }} + version-tag: ${{ github.event.inputs.from-tag-name }} + should-exist: true + - name: Generate Release Notes id: generate_release_notes uses: AbsaOSS/generate-release-notes@master @@ -51,6 +65,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag-name: ${{ github.event.inputs.tag-name }} + from-tag-name: ${{ github.event.inputs.from-tag-name }} chapters: '[ {"title": "No entry 🚫", "label": "duplicate"}, {"title": "No entry 🚫", "label": "invalid"}, diff --git a/action.yml b/action.yml index 20afc178..118467d6 100644 --- a/action.yml +++ b/action.yml @@ -27,6 +27,10 @@ inputs: description: 'Allow duplicity of issue lines in chapters. Scopes: custom, service, both, none.' required: false default: 'both' + from-tag-name: + description: 'The tag name of the previous release to use as a start reference point for the current release notes.' + required: false + default: '' duplicity-icon: description: 'Icon to be used for duplicity warning. Icon is placed before the record line.' required: false @@ -115,6 +119,7 @@ runs: INPUT_GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} INPUT_TAG_NAME: ${{ inputs.tag-name }} INPUT_CHAPTERS: ${{ inputs.chapters }} + INPUT_FROM_TAG_NAME: ${{ inputs.from-tag-name }} INPUT_DUPLICITY_SCOPE: ${{ inputs.duplicity-scope }} INPUT_DUPLICITY_ICON: ${{ inputs.duplicity-icon }} INPUT_WARNINGS: ${{ inputs.warnings }} diff --git a/examples/release_draft.yml b/examples/release_draft.yml index 06905127..eaa3a8e2 100644 --- a/examples/release_draft.yml +++ b/examples/release_draft.yml @@ -5,6 +5,9 @@ on: tag-name: description: 'Name of git tag to be created, and then draft release created. Syntax: "v[0-9]+.[0-9]+.[0-9]+".' required: true + from-tag-name: + description: 'Name of the git tag from which to detect changes from. Default value: latest tag. Syntax: "v[0-9]+.[0-9]+.[0-9]+".' + required: false jobs: release-draft: @@ -23,14 +26,24 @@ jobs: - name: Check format of received tag id: check-version-tag - uses: AbsaOSS/version-tag-check@v0.1.0 + uses: AbsaOSS/version-tag-check@v0.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-repository: ${{ github.repository }} - branch: 'master' version-tag: ${{ github.event.inputs.tag-name }} + - name: Check format of received from tag + if: ${{ github.event.inputs.from-tag-name }} + id: check-version-from-tag + uses: AbsaOSS/version-tag-check@v0.3.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-repository: ${{ github.repository }} + version-tag: ${{ github.event.inputs.from-tag-name }} + should-exist: true + - name: Generate Release Notes id: generate_release_notes uses: AbsaOSS/generate-release-notes@v0.3.0 @@ -38,6 +51,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag-name: ${{ github.event.inputs.tag-name }} + from-tag-name: ${{ github.event.inputs.from-tag-name }} chapters: '[ {"title": "No entry 🚫", "label": "duplicate"}, {"title": "No entry 🚫", "label": "invalid"}, diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 79465696..96fad0cf 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -43,6 +43,7 @@ RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT, SUPPORTED_ROW_FORMAT_KEYS, + FROM_TAG_NAME, ) from release_notes_generator.utils.enums import DuplicityScopeEnum from release_notes_generator.utils.gh_action import get_action_input @@ -81,6 +82,21 @@ def get_tag_name() -> str: """ return get_action_input(TAG_NAME) + @staticmethod + def get_from_tag_name() -> str: + """ + Get the from-tag name from the action inputs. + """ + return get_action_input(FROM_TAG_NAME, default="") + + @staticmethod + def is_from_tag_name_defined() -> bool: + """ + Check if the from-tag name is defined in the action inputs. + """ + value = ActionInputs.get_from_tag_name() + return value.strip() != "" + @staticmethod def get_chapters_json() -> str: """ @@ -120,8 +136,9 @@ def get_skip_release_notes_labels() -> str: """ Get the skip release notes label from the action inputs. """ - user_choice = [item.strip() for item in get_action_input(SKIP_RELEASE_NOTES_LABELS, "").split(",")] - if len(user_choice) > 0: + user_input = get_action_input(SKIP_RELEASE_NOTES_LABELS, "") + user_choice = [item.strip() for item in user_input.split(",")] if user_input else [] + if user_choice: return user_choice return ["skip-release-notes"] @@ -222,6 +239,10 @@ def validate_inputs() -> None: if not isinstance(tag_name, str) or not tag_name.strip(): errors.append("Tag name must be a non-empty string.") + from_tag_name = ActionInputs.get_from_tag_name() + if not isinstance(from_tag_name, str): + errors.append("From tag name must be a string.") + chapters_json = ActionInputs.get_chapters_json() try: json.loads(chapters_json) @@ -294,9 +315,10 @@ def _detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue" cleaned_row_format = row_format for invalid_keyword in invalid_keywords: logger.error( - "Invalid `{}` detected in `{}` row format keyword(s) found: {}. Will be removed from string.".format( - invalid_keyword, row_type, ", ".join(invalid_keywords) - ) + "Invalid `%s` detected in `%s` row format keyword(s) found: %s. Will be removed from string.", + invalid_keyword, + row_type, + ", ".join(invalid_keywords), ) if clean: cleaned_row_format = cleaned_row_format.replace(f"{{{invalid_keyword}}}", "") diff --git a/release_notes_generator/builder.py b/release_notes_generator/builder.py index 37df7cad..360a4172 100644 --- a/release_notes_generator/builder.py +++ b/release_notes_generator/builder.py @@ -29,7 +29,6 @@ logger = logging.getLogger(__name__) -# TODO - reduce to function only after implementing the features. Will be supported more build ways? # pylint: disable=too-few-public-methods class ReleaseNotesBuilder: """ diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index ddd083d1..762007b4 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -20,9 +20,12 @@ """ import logging +import sys from typing import Optional from github import Github +from github.GitRelease import GitRelease +from github.Repository import Repository from release_notes_generator.model.custom_chapters import CustomChapters from release_notes_generator.model.record import Record @@ -72,15 +75,13 @@ def generate(self) -> Optional[str]: @return: The generated release notes as a string, or None if the repository could not be found. """ + # get the repository repo = self._safe_call(self.github_instance.get_repo)(ActionInputs.get_github_repository()) if repo is None: return None - rls = self._safe_call(repo.get_latest_release)() - if rls is None: - logger.info("Latest release not found for %s. 1st release for repository!", repo.full_name) - else: - logger.debug("RLS created_at: %s, published_at: %s", rls.created_at, rls.published_at) + # get the latest release + rls: GitRelease = self.get_latest_release(repo) # default is repository creation date if no releases OR created_at of latest release since = rls.created_at if rls else repo.created_at @@ -97,12 +98,12 @@ def generate(self) -> Optional[str]: # filter out closed Issues before the date issues = list( - filter(lambda issue: issue.closed_at is not None and issue.closed_at > since, list(issues_all)) + filter(lambda issue: issue.closed_at is not None and issue.closed_at >= since, list(issues_all)) ) logger.debug("Count of issues reduced from %d to %d", len(list(issues_all)), len(issues)) # filter out merged PRs and commits before the date - pulls = list(filter(lambda pull: pull.merged_at is not None and pull.merged_at > since, list(pulls_all))) + pulls = list(filter(lambda pull: pull.merged_at is not None and pull.merged_at >= since, list(pulls_all))) logger.debug("Count of pulls reduced from %d to %d", len(list(pulls_all)), len(pulls)) commits = list(filter(lambda commit: commit.commit.author.date > since, list(commits_all))) @@ -125,3 +126,35 @@ def generate(self) -> Optional[str]: ) return release_notes_builder.build() + + def get_latest_release(self, repo: Repository) -> Optional[GitRelease]: + """ + Get the latest release of the repository. + + @param repo: The repository to get the latest release from. + @return: The latest release of the repository, or None if no releases are found. + """ + 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()) + + if rls is None: + logger.info("Latest release not found for received tag %s. Ending!", ActionInputs.get_from_tag_name()) + sys.exit(1) + + else: + logger.info("Getting latest release by time.") + rls: GitRelease = self._safe_call(repo.get_latest_release)() + + if rls is None: + logger.info("Latest release not found for %s. 1st release for repository!", repo.full_name) + + if rls is not None: + logger.debug( + "Latest release with tag:'%s' created_at: %s, published_at: %s", + rls.tag_name, + rls.created_at, + rls.published_at, + ) + + return rls diff --git a/release_notes_generator/record/record_factory.py b/release_notes_generator/record/record_factory.py index 99e460d9..ee9541a5 100644 --- a/release_notes_generator/record/record_factory.py +++ b/release_notes_generator/record/record_factory.py @@ -68,7 +68,7 @@ def create_record_for_issue(r: Repository, i: Issue) -> None: @return: None """ # check for skip labels presence and skip when detected - issue_labels = [label.name for label in issue.labels] + issue_labels = [label.name for label in i.labels] skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) records[i.number] = Record(r, i, skip=skip_record) diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index b4137124..4733f63a 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -22,6 +22,7 @@ GITHUB_REPOSITORY = "GITHUB_REPOSITORY" GITHUB_TOKEN = "github-token" TAG_NAME = "tag-name" +FROM_TAG_NAME = "from-tag-name" CHAPTERS = "chapters" DUPLICITY_SCOPE = "duplicity-scope" DUPLICITY_ICON = "duplicity-icon" diff --git a/release_notes_generator/utils/pull_reuqest_utils.py b/release_notes_generator/utils/pull_reuqest_utils.py index 66629de9..a1d8b9c3 100644 --- a/release_notes_generator/utils/pull_reuqest_utils.py +++ b/release_notes_generator/utils/pull_reuqest_utils.py @@ -14,6 +14,10 @@ # limitations under the License. # +""" +This module contains the PullRequestUtils class which is responsible for extracting information from pull requests. +""" + import re from github.PullRequest import PullRequest diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 2afc31b0..a3d4ced1 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -23,6 +23,7 @@ success_case = { "get_github_repository": "owner/repo_name", "get_tag_name": "tag_name", + "get_from_tag_name": "from_tag_name", "get_chapters_json": '{"chapter": "content"}', "get_duplicity_scope": "custom", "get_duplicity_icon": "🔁", @@ -31,12 +32,14 @@ "get_skip_release_notes_labels": ["skip"], "get_print_empty_chapters": True, "get_verbose": True, + "get_release_notes_title": "Success value", } failure_cases = [ ("get_github_repository", "", "Owner and Repo must be a non-empty string."), ("get_github_repository", "owner/", "Owner and Repo must be a non-empty string."), ("get_tag_name", "", "Tag name must be a non-empty string."), + ("get_from_tag_name", 1, "From tag name must be a string."), ("get_chapters_json", "invalid_json", "Chapters JSON must be a valid JSON string."), ("get_warnings", "not_bool", "Warnings must be a boolean."), ("get_published_at", "not_bool", "Published at must be a boolean."), @@ -46,6 +49,7 @@ ("get_duplicity_icon", "Oj", "Duplicity icon must be a non-empty string and have a length of 1."), ("get_row_format_issue", "", "Issue row format must be a non-empty string."), ("get_row_format_pr", "", "PR Row format must be a non-empty string."), + ("get_release_notes_title", "", "Release Notes title must be a non-empty string and have non-zero length."), ] @@ -123,14 +127,21 @@ def test_get_skip_release_notes_label(mocker): mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="skip-release-notes") assert ActionInputs.get_skip_release_notes_labels() == ["skip-release-notes"] + +def test_get_skip_release_notes_label_not_defined(mocker): + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="") + assert ActionInputs.get_skip_release_notes_labels() == ["skip-release-notes"] + def test_get_skip_release_notes_labels(mocker): mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="skip-release-notes, another-skip-label") assert ActionInputs.get_skip_release_notes_labels() == ["skip-release-notes", "another-skip-label"] + def test_get_skip_release_notes_labels_no_space(mocker): mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="skip-release-notes,another-skip-label") assert ActionInputs.get_skip_release_notes_labels() == ["skip-release-notes", "another-skip-label"] + def test_get_print_empty_chapters(mocker): mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="true") assert ActionInputs.get_print_empty_chapters() is True @@ -200,3 +211,11 @@ def test_clean_row_format_invalid_keywords_nested_braces(): actual_format = ActionInputs._detect_row_format_invalid_keywords(row_format, clean=True) assert expected_format == actual_format + +def test_release_notes_title_default(): + assert ActionInputs.get_release_notes_title() == "[Rr]elease [Nn]otes:" + + +def test_release_notes_title_custom(mocker): + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="Custom Title") + assert ActionInputs.get_release_notes_title() == "Custom Title" diff --git a/tests/test_release_notes_generator.py b/tests/test_release_notes_generator.py index e228d5c5..82c784a5 100644 --- a/tests/test_release_notes_generator.py +++ b/tests/test_release_notes_generator.py @@ -18,6 +18,7 @@ from datetime import datetime, timedelta from github import Github +from github.GitRelease import GitRelease from release_notes_generator.generator import ReleaseNotesGenerator from release_notes_generator.model.custom_chapters import CustomChapters @@ -75,7 +76,6 @@ def test_generate_release_notes_latest_release_not_found( release_notes = ReleaseNotesGenerator(github_mock, custom_chapters).generate() - print(release_notes) assert release_notes is not None assert "- #121 _Fix the bug_" in release_notes assert "- #122 _I1+bug_" in release_notes @@ -171,3 +171,55 @@ def test_generate_release_notes_latest_release_found_by_published_at( assert "- #122 _I1+bug_" in release_notes assert "- PR: #101 _Fixed bug_" not in release_notes assert "- PR: #102 _Fixed bug_" in release_notes + + +# get_latest_release tests + +def test_get_latest_release_from_tag_name_defined_no_release(mocker, mock_repo): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.is_from_tag_name_defined", return_value=True) + mock_exit = mocker.patch("sys.exit") + 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_release.return_value = None + + 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 + mock_exit.assert_called_once_with(1) + assert mock_log_info.called_with(2) + assert ('Getting latest release by from-tag name %s', None) == mock_log_info.call_args_list[0][0] + assert ('Latest release not found for received tag %s. Ending!', '') == mock_log_info.call_args_list[1][0] + + +def test_get_latest_release_from_tag_name_defined_release_exists(mocker, mock_repo): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.is_from_tag_name_defined", return_value=True) + mock_exit = mocker.patch("sys.exit") + 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 + + rls_mock = mocker.Mock(spec=GitRelease) + mock_repo.get_release.return_value = rls_mock + + 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 rls_mock == latest_release + 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]