diff --git a/pyproject.toml b/pyproject.toml index dac59175b..87efb1502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -399,7 +399,7 @@ ignore_names = ["change_to_ex_proj_dir", "init_example_project"] [tool.semantic_release] logging_use_named_masks = true commit_parser = "angular" -commit_parser_options = { parse_squash_commits = true } +commit_parser_options = { parse_squash_commits = true, ignore_merge_commits = true } build_command = """ python -m pip install -e .[build] python -m build . diff --git a/src/semantic_release/changelog/release_history.py b/src/semantic_release/changelog/release_history.py index f9e90221e..887fa5492 100644 --- a/src/semantic_release/changelog/release_history.py +++ b/src/semantic_release/changelog/release_history.py @@ -52,6 +52,12 @@ def from_git_history( for tag, version in all_git_tags_and_versions } + ignore_merge_commits = bool( + hasattr(commit_parser, "options") + and hasattr(commit_parser.options, "ignore_merge_commits") + and getattr(commit_parser.options, "ignore_merge_commits") # noqa: B009 + ) + # Strategy: # Loop through commits in history, parsing as we go. # Add these commits to `unreleased` as a key-value mapping @@ -159,6 +165,10 @@ def from_git_history( else parsed_result.bump ) + if ignore_merge_commits and parsed_result.is_merge_commit(): + log.info("Excluding merge commit[%s]", parsed_result.short_hash) + continue + # Skip excluded commits except for any commit causing a version bump # Reasoning: if a commit causes a version bump, and no other commits # are included, then the changelog will be empty. Even if ther was other diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 511d73a38..80f76571a 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -101,6 +101,10 @@ class AngularParserOptions(ParserOptions): parse_squash_commits: bool = False """Toggle flag for whether or not to parse squash commits""" + # TODO: breaking change v10, change default to True + ignore_merge_commits: bool = False + """Toggle flag for whether or not to ignore merge commits""" + @property def tag_to_level(self) -> dict[str, LevelBump]: """A mapping of commit tags to the level bump they should result in.""" @@ -315,6 +319,10 @@ def parse_message(self, message: str) -> ParsedMessageResult | None: linked_merge_request=linked_merge_request, ) + @staticmethod + def is_merge_commit(commit: Commit) -> bool: + return len(commit.parents) > 1 + def parse_commit(self, commit: Commit) -> ParseResult: if not (parsed_msg_result := self.parse_message(force_str(commit.message))): return _logged_parse_error( @@ -336,6 +344,11 @@ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: multiple commits, each of which will be parsed separately. Single commits will be returned as a list of a single ParseResult. """ + if self.options.ignore_merge_commits and self.is_merge_commit(commit): + return _logged_parse_error( + commit, "Ignoring merge commit: %s" % commit.hexsha[:8] + ) + separate_commits: list[Commit] = ( self.unsquash_commit(commit) if self.options.parse_squash_commits diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index 5b8479f18..fa0ad2304 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -17,6 +17,7 @@ from semantic_release.commit_parser.token import ( ParsedCommit, ParsedMessageResult, + ParseError, ParseResult, ) from semantic_release.commit_parser.util import ( @@ -97,6 +98,10 @@ class EmojiParserOptions(ParserOptions): parse_squash_commits: bool = False """Toggle flag for whether or not to parse squash commits""" + # TODO: breaking change v10, change default to True + ignore_merge_commits: bool = False + """Toggle flag for whether or not to ignore merge commits""" + @property def tag_to_level(self) -> dict[str, LevelBump]: """A mapping of commit tags to the level bump they should result in.""" @@ -304,6 +309,10 @@ def parse_message(self, message: str) -> ParsedMessageResult: linked_merge_request=linked_merge_request, ) + @staticmethod + def is_merge_commit(commit: Commit) -> bool: + return len(commit.parents) > 1 + def parse_commit(self, commit: Commit) -> ParseResult: return ParsedCommit.from_parsed_message_result( commit, self.parse_message(force_str(commit.message)) @@ -317,6 +326,11 @@ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: multiple commits, each of which will be parsed separately. Single commits will be returned as a list of a single ParseResult. """ + if self.options.ignore_merge_commits and self.is_merge_commit(commit): + err_msg = "Ignoring merge commit: %s" % commit.hexsha[:8] + logger.debug(err_msg) + return ParseError(commit, err_msg) + separate_commits: list[Commit] = ( self.unsquash_commit(commit) if self.options.parse_squash_commits diff --git a/src/semantic_release/commit_parser/token.py b/src/semantic_release/commit_parser/token.py index c100bad84..dae8d6f59 100644 --- a/src/semantic_release/commit_parser/token.py +++ b/src/semantic_release/commit_parser/token.py @@ -131,6 +131,9 @@ def linked_pull_request(self) -> str: """An alias to the linked_merge_request attribute.""" return self.linked_merge_request + def is_merge_commit(self) -> bool: + return bool(len(self.commit.parents) > 1) + @staticmethod def from_parsed_message_result( commit: Commit, parsed_message_result: ParsedMessageResult @@ -185,6 +188,9 @@ def short_hash(self) -> str: """A short representation of the hash value (in hex) of the commit.""" return self.hexsha[:7] + def is_merge_commit(self) -> bool: + return bool(len(self.commit.parents) > 1) + def raise_error(self) -> NoReturn: """A convience method to raise a CommitParseError with the error message.""" raise CommitParseError(self.error) diff --git a/src/semantic_release/data/templates/angular/md/.components/changes.md.j2 b/src/semantic_release/data/templates/angular/md/.components/changes.md.j2 index fe1069447..2bade3b74 100644 --- a/src/semantic_release/data/templates/angular/md/.components/changes.md.j2 +++ b/src/semantic_release/data/templates/angular/md/.components/changes.md.j2 @@ -51,11 +51,13 @@ EXAMPLE: }}{% endfor %}{# # # PRINT SECTION (header & commits) -#}{{ "\n" -}}{{ "### %s\n" | format(type_ | title) -}}{{ "\n" -}}{{ "%s\n" | format(commit_descriptions | unique | join("\n\n")) -}}{% endfor +#}{% if commit_descriptions | length > 0 +%}{{ "\n" +}}{{ "### %s\n" | format(type_ | title) +}}{{ "\n" +}}{{ "%s\n" | format(commit_descriptions | unique | join("\n\n")) +}}{% endif +%}{% endfor %}{# # Determine if there are any breaking change commits by filtering the list by breaking descriptions # commit_objects is a list of tuples [("Features", [ParsedCommit(), ...]), ("Bug Fixes", [ParsedCommit(), ...])] diff --git a/src/semantic_release/data/templates/angular/rst/.components/changes.rst.j2 b/src/semantic_release/data/templates/angular/rst/.components/changes.rst.j2 index d064838f6..07d6729d3 100644 --- a/src/semantic_release/data/templates/angular/rst/.components/changes.rst.j2 +++ b/src/semantic_release/data/templates/angular/rst/.components/changes.rst.j2 @@ -78,13 +78,14 @@ BREAKING CHANGES }}{% endfor %}{# # # PRINT SECTION (Header & Commits) -#}{{ "\n" -}}{{ section_header ~ "\n" -}}{{ generate_heading_underline(section_header, '-') ~ "\n" -}}{{ - "\n%s\n" | format(commit_descriptions | unique | join("\n\n")) - -}}{% endfor +#}{% if commit_descriptions | length > 0 +%}{{ "\n" +}}{{ section_header ~ "\n" +}}{{ generate_heading_underline(section_header, '-') +}}{{ "\n" +}}{{ "\n%s\n" | format(commit_descriptions | unique | join("\n\n")) +}}{% endif +%}{% endfor %}{# # Determine if there are any breaking change commits by filtering the list by breaking descriptions # commit_objects is a list of tuples [("Features", [ParsedCommit(), ...]), ("Bug Fixes", [ParsedCommit(), ...])] diff --git a/tests/e2e/cmd_changelog/test_changelog_parsing.py b/tests/e2e/cmd_changelog/test_changelog_parsing.py new file mode 100644 index 000000000..3f8dc052a --- /dev/null +++ b/tests/e2e/cmd_changelog/test_changelog_parsing.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path +from re import MULTILINE, compile as regexp +from typing import TYPE_CHECKING + +import pytest +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.changelog.context import ChangelogMode +from semantic_release.cli.commands.main import main +from semantic_release.cli.const import JINJA2_EXTENSION + +from tests.const import CHANGELOG_SUBCMD, MAIN_PROG_NAME +from tests.fixtures.example_project import ( + default_changelog_md_template, + default_changelog_rst_template, + default_md_changelog_insertion_flag, + default_rst_changelog_insertion_flag, + example_changelog_md, + example_changelog_rst, +) +from tests.fixtures.repos.git_flow import ( + repo_w_git_flow_angular_commits, + repo_w_git_flow_scipy_commits, +) +from tests.util import assert_successful_exit_code + +if TYPE_CHECKING: + from click.testing import CliRunner + + from tests.fixtures.example_project import UpdatePyprojectTomlFn + from tests.fixtures.git_repo import BuiltRepoResult + + +@pytest.mark.parametrize( + "changelog_file, insertion_flag, default_changelog_template, changes_tpl_file", + [ + ( + # ChangelogOutputFormat.MARKDOWN + lazy_fixture(example_changelog_md.__name__), + lazy_fixture(default_md_changelog_insertion_flag.__name__), + lazy_fixture(default_changelog_md_template.__name__), + Path(".components", "changes.md.j2"), + ), + ( + # ChangelogOutputFormat.RESTRUCTURED_TEXT + lazy_fixture(example_changelog_rst.__name__), + lazy_fixture(default_rst_changelog_insertion_flag.__name__), + lazy_fixture(default_changelog_rst_template.__name__), + Path(".components", "changes.rst.j2"), + ), + ], +) +@pytest.mark.parametrize( + "repo_result", + [ + pytest.param( + lazy_fixture(repo_fixture_name), + marks=pytest.mark.comprehensive, + ) + for repo_fixture_name in [ + repo_w_git_flow_angular_commits.__name__, + repo_w_git_flow_scipy_commits.__name__, + ] + ], +) +def test_changelog_parsing_ignore_merge_commits( + cli_runner: CliRunner, + repo_result: BuiltRepoResult, + update_pyproject_toml: UpdatePyprojectTomlFn, + example_project_template_dir: Path, + changelog_file: Path, + insertion_flag: str, + default_changelog_template: Path, + changes_tpl_file: Path, +): + repo = repo_result["repo"] + expected_changelog_content = changelog_file.read_text() + + update_pyproject_toml( + "tool.semantic_release.commit_parser_options.ignore_merge_commits", True + ) + update_pyproject_toml( + "tool.semantic_release.changelog.mode", ChangelogMode.UPDATE.value + ) + update_pyproject_toml( + "tool.semantic_release.changelog.insertion_flag", + insertion_flag, + ) + update_pyproject_toml( + "tool.semantic_release.changelog.template_dir", + str(example_project_template_dir.relative_to(repo.working_dir)), + ) + update_pyproject_toml( + "tool.semantic_release.changelog.exclude_commit_patterns", + [ + r"""Initial Commit.*""", + ], + ) + + # Force custom changelog to be a copy of the default changelog + shutil.copytree( + src=default_changelog_template.parent, + dst=example_project_template_dir, + dirs_exist_ok=True, + ) + + # Remove the "unknown" filter from the changelog template to enable Merge commits + patch = regexp( + r'^(#}{% *for type_, commits in commit_objects) if type_ != "unknown"', + MULTILINE, + ) + changes_file = example_project_template_dir.joinpath(changes_tpl_file) + changes_file.write_text(patch.sub(r"\1", changes_file.read_text())) + + # Make sure the prev_changelog_file is the same as the current changelog + changelog_tpl_file = example_project_template_dir.joinpath( + changelog_file.name + ).with_suffix(str.join("", [changelog_file.suffix, JINJA2_EXTENSION])) + changelog_tpl_file.write_text( + regexp(r"= ctx.prev_changelog_file").sub( + rf'= "{changelog_file.name}"', changelog_tpl_file.read_text() + ) + ) + + # Remove the changelog to force re-generation with new configurations + os.remove(str(changelog_file.resolve())) + + # Act + cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert expected_changelog_content == changelog_file.read_text() diff --git a/tests/unit/semantic_release/commit_parser/test_angular.py b/tests/unit/semantic_release/commit_parser/test_angular.py index 1ce75734a..5fc69feb1 100644 --- a/tests/unit/semantic_release/commit_parser/test_angular.py +++ b/tests/unit/semantic_release/commit_parser/test_angular.py @@ -1129,3 +1129,31 @@ def test_parser_custom_patch_tags(make_commit_obj: MakeCommitObjFn): result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert result.bump is LevelBump.PATCH + + +def test_parser_ignore_merge_commit( + default_angular_parser: AngularCommitParser, + make_commit_obj: MakeCommitObjFn, +): + # Setup: Enable parsing of linked issues + parser = AngularCommitParser( + options=AngularParserOptions( + **{ + **default_angular_parser.options.__dict__, + "ignore_merge_commits": True, + } + ) + ) + + base_commit = make_commit_obj("Merge branch 'fix/fix-feature' into 'main'") + incomming_commit = make_commit_obj("feat: add a new feature") + + # Setup: Create a merge commit + merge_commit = make_commit_obj("Merge branch 'feat/add-new-feature' into 'main'") + merge_commit.parents = [base_commit, incomming_commit] + + # Action + parsed_result = parser.parse(merge_commit) + + assert isinstance(parsed_result, ParseError) + assert "Ignoring merge commit" in parsed_result.error diff --git a/tests/unit/semantic_release/commit_parser/test_emoji.py b/tests/unit/semantic_release/commit_parser/test_emoji.py index 30c52da41..b5e172c52 100644 --- a/tests/unit/semantic_release/commit_parser/test_emoji.py +++ b/tests/unit/semantic_release/commit_parser/test_emoji.py @@ -991,3 +991,31 @@ def test_parser_squashed_commit_github_squash_style( assert expected.get("breaking_descriptions", []) == result.breaking_descriptions assert expected.get("linked_issues", ()) == result.linked_issues assert expected.get("linked_merge_request", "") == result.linked_merge_request + + +def test_parser_ignore_merge_commit( + default_emoji_parser: EmojiCommitParser, + make_commit_obj: MakeCommitObjFn, +): + # Setup: Enable parsing of linked issues + parser = EmojiCommitParser( + options=EmojiParserOptions( + **{ + **default_emoji_parser.options.__dict__, + "ignore_merge_commits": True, + } + ) + ) + + base_commit = make_commit_obj("Merge branch 'fix/fix-feature' into 'main'") + incomming_commit = make_commit_obj("feat: add a new feature") + + # Setup: Create a merge commit + merge_commit = make_commit_obj("Merge branch 'feat/add-new-feature' into 'main'") + merge_commit.parents = [base_commit, incomming_commit] + + # Action + parsed_result = parser.parse(merge_commit) + + assert isinstance(parsed_result, ParseError) + assert "Ignoring merge commit" in parsed_result.error diff --git a/tests/unit/semantic_release/commit_parser/test_scipy.py b/tests/unit/semantic_release/commit_parser/test_scipy.py index 8fc64fea4..7682b1c69 100644 --- a/tests/unit/semantic_release/commit_parser/test_scipy.py +++ b/tests/unit/semantic_release/commit_parser/test_scipy.py @@ -1029,3 +1029,31 @@ def test_parser_return_linked_issues_from_commit_message( result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert tuple(linked_issues) == result.linked_issues + + +def test_parser_ignore_merge_commit( + default_scipy_parser: ScipyCommitParser, + make_commit_obj: MakeCommitObjFn, +): + # Setup: Enable parsing of linked issues + parser = ScipyCommitParser( + options=ScipyParserOptions( + **{ + **default_scipy_parser.options.__dict__, + "ignore_merge_commits": True, + } + ) + ) + + base_commit = make_commit_obj("Merge branch 'fix/fix-feature' into 'main'") + incomming_commit = make_commit_obj("feat: add a new feature") + + # Setup: Create a merge commit + merge_commit = make_commit_obj("Merge branch 'feat/add-new-feature' into 'main'") + merge_commit.parents = [base_commit, incomming_commit] + + # Action + parsed_result = parser.parse(merge_commit) + + assert isinstance(parsed_result, ParseError) + assert "Ignoring merge commit" in parsed_result.error