Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(parsers): add option ignore_merge_commits to discard parsing merge commits #1164

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 2 pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
10 changes: 10 additions & 0 deletions 10 src/semantic_release/changelog/release_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions 13 src/semantic_release/commit_parser/angular.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions 14 src/semantic_release/commit_parser/emoji.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from semantic_release.commit_parser.token import (
ParsedCommit,
ParsedMessageResult,
ParseError,
ParseResult,
)
from semantic_release.commit_parser.util import (
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions 6 src/semantic_release/commit_parser/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(), ...])]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(), ...])]
Expand Down
138 changes: 138 additions & 0 deletions 138 tests/e2e/cmd_changelog/test_changelog_parsing.py
Original file line number Diff line number Diff line change
@@ -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()
28 changes: 28 additions & 0 deletions 28 tests/unit/semantic_release/commit_parser/test_angular.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions 28 tests/unit/semantic_release/commit_parser/test_emoji.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.