From 7a47bb82e6680b0b198d9e4dc8b6240cffd42cce Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 18 Dec 2024 00:17:13 -0700 Subject: [PATCH 01/16] test(parser-angular): update unit tests for parser return value compatibility --- tests/conftest.py | 16 +- .../commit_parser/test_angular.py | 701 ++++++++++++++++-- 2 files changed, 666 insertions(+), 51 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 858f9efd6..933a0cfd1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ from typing import Any, Callable, Generator, Protocol, Sequence, TypedDict from filelock import AcquireReturnProxy + from git import Actor from tests.fixtures.git_repo import RepoActions @@ -382,9 +383,20 @@ def _teardown_cached_dir(directory: Path | str) -> Path: @pytest.fixture(scope="session") -def make_commit_obj() -> MakeCommitObjFn: +def make_commit_obj( + commit_author: Actor, stable_now_date: GetStableDateNowFn +) -> MakeCommitObjFn: def _make_commit(message: str) -> Commit: - return Commit(repo=Repo(), binsha=Commit.NULL_BIN_SHA, message=message) + commit_timestamp = round(stable_now_date().timestamp()) + return Commit( + repo=Repo(), + binsha=Commit.NULL_BIN_SHA, + message=message, + author=commit_author, + authored_date=commit_timestamp, + committer=commit_author, + committed_date=commit_timestamp, + ) return _make_commit diff --git a/tests/unit/semantic_release/commit_parser/test_angular.py b/tests/unit/semantic_release/commit_parser/test_angular.py index b7bf91aac..1ce75734a 100644 --- a/tests/unit/semantic_release/commit_parser/test_angular.py +++ b/tests/unit/semantic_release/commit_parser/test_angular.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Sequence +from textwrap import dedent +from typing import TYPE_CHECKING, Iterable, Sequence import pytest @@ -17,17 +18,556 @@ from tests.conftest import MakeCommitObjFn +# NOTE: GitLab squash commits are not tested because by default +# they don't have any unique attributes of them and they are also +# fully customizable. +# See https://docs.gitlab.com/ee/user/project/merge_requests/commit_templates.html +# It also depends if Fast-Forward merge is enabled because that will +# define if there is a merge commit or not and with that likely no +# Merge Request Number included unless the user adds it. +# TODO: add the recommendation in the PSR documentation is to set your GitLab templates +# to mirror GitHub like references in the first subject line. Will Not matter +# if fast-forward merge is enabled or not. + + +@pytest.mark.parametrize( + "commit_message", ["", "feat(parser\n): Add new parser pattern"] +) def test_parser_raises_unknown_message_style( - default_angular_parser: AngularCommitParser, make_commit_obj: MakeCommitObjFn + default_angular_parser: AngularCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, ): - assert isinstance(default_angular_parser.parse(make_commit_obj("")), ParseError) - assert isinstance( - default_angular_parser.parse( - make_commit_obj("feat(parser\n): Add new parser pattern") - ), - ParseError, + parsed_results = default_angular_parser.parse(make_commit_obj(commit_message)) + assert isinstance(parsed_results, Iterable) + for result in parsed_results: + assert isinstance(result, ParseError) + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via BitBucket PR resolution", + dedent( + """\ + Merged in feat/my-awesome-stuff (pull request #10) + + fix(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + """ + ), + [ + None, + { + "bump": LevelBump.PATCH, + "type": "bug fixes", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + ], + ), + ( + "Multiple commits squashed via BitBucket PR resolution", + dedent( + """\ + Merged in feat/my-awesome-stuff (pull request #10) + + fix(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + feat: implemented searching gizmos by keyword + + docs(parser): add new parser pattern + + fix(cli)!: changed option name + + BREAKING CHANGE: A breaking change description + + Closes: #555 + + invalid non-conventional formatted commit + """ + ), + [ + None, + { + "bump": LevelBump.PATCH, + "type": "bug fixes", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MINOR, + "type": "features", + "descriptions": ["implemented searching gizmos by keyword"], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.NO_RELEASE, + "type": "documentation", + "scope": "parser", + "descriptions": [ + "add new parser pattern", + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MAJOR, + "type": "bug fixes", + "scope": "cli", + "descriptions": [ + "changed option name", + "BREAKING CHANGE: A breaking change description", + "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "invalid non-conventional formatted commit", + ], + "breaking_descriptions": [ + "A breaking change description", + ], + "linked_issues": ("#555",), + "linked_merge_request": "#10", + }, + ], + ), + ] + ], +) +def test_parser_squashed_commit_bitbucket_squash_style( + default_angular_parser: AngularCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = AngularCommitParser( + options=AngularParserOptions( + **{ + **default_angular_parser.options.__dict__, + "parse_squash_commits": True, + } + ) ) + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via manual Git squash merge", + dedent( + """\ + Squashed commit of the following: + + commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + Author: author + Date: Sun Jan 19 12:05:23 2025 +0000 + + fix(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "bug fixes", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + } + ], + ), + ( + "Multiple commits squashed via manual Git squash merge", + dedent( + """\ + Squashed commit of the following: + + commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + Author: author + Date: Sun Jan 19 12:05:23 2025 +0000 + + fix(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + commit 1f34769bf8352131ad6f4879b8c47becf3c7aa69 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + feat: implemented searching gizmos by keyword + + commit b2334a64a11ef745a17a2a4034f651e08e8c45a6 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + docs(parser): add new parser pattern + + commit 5f0292fb5a88c3a46e4a02bec35b85f5228e8e51 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + fix(cli)!: changed option name + + BREAKING CHANGE: A breaking change description + + Closes: #555 + + commit 2f314e7924be161cfbf220d3b6e2a6189a3b5609 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + invalid non-conventional formatted commit + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "bug fixes", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + }, + { + "bump": LevelBump.MINOR, + "type": "features", + "descriptions": ["implemented searching gizmos by keyword"], + }, + { + "bump": LevelBump.NO_RELEASE, + "type": "documentation", + "scope": "parser", + "descriptions": [ + "add new parser pattern", + ], + }, + { + "bump": LevelBump.MAJOR, + "type": "bug fixes", + "scope": "cli", + "descriptions": [ + "changed option name", + "BREAKING CHANGE: A breaking change description", + "Closes: #555", + ], + "breaking_descriptions": [ + "A breaking change description", + ], + "linked_issues": ("#555",), + }, + None, + ], + ), + ] + ], +) +def test_parser_squashed_commit_git_squash_style( + default_angular_parser: AngularCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = AngularCommitParser( + options=AngularParserOptions( + **{ + **default_angular_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via GitHub PR resolution", + dedent( + """\ + fix(release-config): some commit subject (#10) + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "bug fixes", + "scope": "release-config", + "descriptions": [ + # TODO: v10 removal of PR number from subject + "some commit subject (#10)", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + ], + ), + ( + "Multiple commits squashed via GitHub PR resolution", + dedent( + """\ + fix(release-config): some commit subject (#10) + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + * feat: implemented searching gizmos by keyword + + * docs(parser): add new parser pattern + + * fix(cli)!: changed option name + + BREAKING CHANGE: A breaking change description + + Closes: #555 + + * invalid non-conventional formatted commit + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "bug fixes", + "scope": "release-config", + "descriptions": [ + # TODO: v10 removal of PR number from subject + "some commit subject (#10)", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MINOR, + "type": "features", + "descriptions": ["implemented searching gizmos by keyword"], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.NO_RELEASE, + "type": "documentation", + "scope": "parser", + "descriptions": [ + "add new parser pattern", + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MAJOR, + "type": "bug fixes", + "scope": "cli", + "descriptions": [ + "changed option name", + "BREAKING CHANGE: A breaking change description", + "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "* invalid non-conventional formatted commit", + ], + "breaking_descriptions": [ + "A breaking change description", + ], + "linked_issues": ("#555",), + "linked_merge_request": "#10", + }, + ], + ), + ] + ], +) +def test_parser_squashed_commit_github_squash_style( + default_angular_parser: AngularCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = AngularCommitParser( + options=AngularParserOptions( + **{ + **default_angular_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + @pytest.mark.parametrize( "commit_message, bump", @@ -46,8 +586,8 @@ def test_parser_raises_unknown_message_style( ("feat(parser): Add emoji parser", LevelBump.MINOR), ("fix(parser): Fix regex in angular parser", LevelBump.PATCH), ("test(parser): Add a test for angular parser", LevelBump.NO_RELEASE), - ("feat(parser)!: Edit dat parsing stuff", LevelBump.MAJOR), - ("fix!: Edit dat parsing stuff again", LevelBump.MAJOR), + ("feat(parser)!: Edit data parsing stuff", LevelBump.MAJOR), + ("fix!: Edit data parsing stuff again", LevelBump.MAJOR), ("fix: superfix", LevelBump.PATCH), ], ) @@ -57,7 +597,12 @@ def test_parser_returns_correct_bump_level( bump: LevelBump, make_commit_obj: MakeCommitObjFn, ): - result = default_angular_parser.parse(make_commit_obj(commit_message)) + parsed_results = default_angular_parser.parse(make_commit_obj(commit_message)) + + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert result.bump is bump @@ -80,7 +625,12 @@ def test_parser_return_type_from_commit_message( type_: str, make_commit_obj: MakeCommitObjFn, ): - result = default_angular_parser.parse(make_commit_obj(message)) + parsed_results = default_angular_parser.parse(make_commit_obj(message)) + + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert result.type == type_ @@ -105,7 +655,12 @@ def test_parser_return_scope_from_commit_message( scope: str, make_commit_obj: MakeCommitObjFn, ): - result = default_angular_parser.parse(make_commit_obj(message)) + parsed_results = default_angular_parser.parse(make_commit_obj(message)) + + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert result.scope == scope @@ -139,7 +694,12 @@ def test_parser_return_subject_from_commit_message( descriptions: list[str], make_commit_obj: MakeCommitObjFn, ): - result = default_angular_parser.parse(make_commit_obj(message)) + parsed_results = default_angular_parser.parse(make_commit_obj(message)) + + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert result.descriptions == descriptions @@ -181,7 +741,12 @@ def test_parser_return_linked_merge_request_from_commit_message( merge_request_number: str, make_commit_obj: MakeCommitObjFn, ): - result = default_angular_parser.parse(make_commit_obj(message)) + parsed_results = default_angular_parser.parse(make_commit_obj(message)) + + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert merge_request_number == result.linked_merge_request assert subject == result.descriptions[0] @@ -466,7 +1031,12 @@ def test_parser_return_linked_issues_from_commit_message( linked_issues: Sequence[str], make_commit_obj: MakeCommitObjFn, ): - result = default_angular_parser.parse(make_commit_obj(message)) + parsed_results = default_angular_parser.parse(make_commit_obj(message)) + + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert tuple(linked_issues) == result.linked_issues @@ -476,53 +1046,86 @@ def test_parser_return_linked_issues_from_commit_message( ############################## def test_parser_custom_default_level(make_commit_obj: MakeCommitObjFn): options = AngularParserOptions(default_bump_level=LevelBump.MINOR) - parser = AngularCommitParser(options) - result = parser.parse( + parsed_results = AngularCommitParser(options).parse( make_commit_obj("test(parser): Add a test for angular parser") ) + + assert isinstance(parsed_results, Iterable) + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert result.bump is LevelBump.MINOR -def test_parser_custom_allowed_types(make_commit_obj: MakeCommitObjFn): - options = AngularParserOptions( - allowed_tags=( - "custom", - "build", - "chore", - "ci", - "docs", - "fix", - "perf", - "style", - "refactor", - "test", +def test_parser_custom_allowed_types( + default_angular_parser: AngularCommitParser, + make_commit_obj: MakeCommitObjFn, +): + new_tag = "custom" + custom_allowed_tags = [*default_angular_parser.options.allowed_tags, new_tag] + parser = AngularCommitParser( + options=AngularParserOptions( + allowed_tags=tuple(custom_allowed_tags), ) ) - parser = AngularCommitParser(options) - res1 = parser.parse(make_commit_obj("custom: ...")) - assert isinstance(res1, ParsedCommit) - assert res1.bump is LevelBump.NO_RELEASE + for commit_type, commit_msg in [ + (new_tag, f"{new_tag}: ..."), # no scope + (new_tag, f"{new_tag}(parser): ..."), # with scope + ("chores", "chore(parser): ..."), # existing, non-release tag + ]: + parsed_results = parser.parse(make_commit_obj(commit_msg)) + assert isinstance(parsed_results, Iterable) + + result = next(iter(parsed_results)) + assert isinstance(result, ParsedCommit) + assert result.type == commit_type + assert result.bump is LevelBump.NO_RELEASE + + +def test_parser_custom_allowed_types_ignores_non_types( + default_angular_parser: AngularCommitParser, make_commit_obj: MakeCommitObjFn +): + banned_tag = "feat" + custom_allowed_tags = [*default_angular_parser.options.allowed_tags] + custom_allowed_tags.remove(banned_tag) + + parser = AngularCommitParser( + options=AngularParserOptions( + allowed_tags=tuple(custom_allowed_tags), + ) + ) - res2 = parser.parse(make_commit_obj("custom(parser): ...")) - assert isinstance(res2, ParsedCommit) - assert res2.type == "custom" + parsed_results = parser.parse(make_commit_obj(f"{banned_tag}(parser): ...")) + assert isinstance(parsed_results, Iterable) - assert isinstance(parser.parse(make_commit_obj("feat(parser): ...")), ParseError) + result = next(iter(parsed_results)) + assert isinstance(result, ParseError) def test_parser_custom_minor_tags(make_commit_obj: MakeCommitObjFn): - options = AngularParserOptions(minor_tags=("docs",)) - parser = AngularCommitParser(options) - res = parser.parse(make_commit_obj("docs: write some docs")) - assert isinstance(res, ParsedCommit) - assert res.bump is LevelBump.MINOR + custom_minor_tag = "docs" + parser = AngularCommitParser( + options=AngularParserOptions(minor_tags=(custom_minor_tag,)) + ) + + parsed_results = parser.parse(make_commit_obj(f"{custom_minor_tag}: ...")) + assert isinstance(parsed_results, Iterable) + + result = next(iter(parsed_results)) + assert isinstance(result, ParsedCommit) + assert result.bump is LevelBump.MINOR def test_parser_custom_patch_tags(make_commit_obj: MakeCommitObjFn): - options = AngularParserOptions(patch_tags=("test",)) - parser = AngularCommitParser(options) - res = parser.parse(make_commit_obj("test(this): added a test")) - assert isinstance(res, ParsedCommit) - assert res.bump is LevelBump.PATCH + custom_patch_tag = "test" + parser = AngularCommitParser( + options=AngularParserOptions(patch_tags=(custom_patch_tag,)) + ) + + parsed_results = parser.parse(make_commit_obj(f"{custom_patch_tag}: ...")) + assert isinstance(parsed_results, Iterable) + + result = next(iter(parsed_results)) + assert isinstance(result, ParsedCommit) + assert result.bump is LevelBump.PATCH From b5c5b38a2be85823324b927dc04e0ce28793ff1a Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 18 Dec 2024 01:04:25 -0700 Subject: [PATCH 02/16] test(parser-scipy): update unit tests for parser return value compatibility --- .../commit_parser/test_scipy.py | 591 +++++++++++++++++- 1 file changed, 576 insertions(+), 15 deletions(-) diff --git a/tests/unit/semantic_release/commit_parser/test_scipy.py b/tests/unit/semantic_release/commit_parser/test_scipy.py index 69b95a5d1..8fc64fea4 100644 --- a/tests/unit/semantic_release/commit_parser/test_scipy.py +++ b/tests/unit/semantic_release/commit_parser/test_scipy.py @@ -1,25 +1,42 @@ from __future__ import annotations from re import compile as regexp -from typing import TYPE_CHECKING, Sequence +from textwrap import dedent +from typing import TYPE_CHECKING, Iterable, Sequence import pytest -from semantic_release.commit_parser.scipy import tag_to_section -from semantic_release.commit_parser.token import ParsedCommit +from semantic_release.commit_parser.scipy import ( + ScipyCommitParser, + ScipyParserOptions, + tag_to_section, +) +from semantic_release.commit_parser.token import ParsedCommit, ParseError from semantic_release.enums import LevelBump from tests.const import SUPPORTED_ISSUE_CLOSURE_PREFIXES if TYPE_CHECKING: - from semantic_release.commit_parser.scipy import ScipyCommitParser - from tests.conftest import MakeCommitObjFn unwordwrap = regexp(r"((? + """ + ), + [ + None, + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + ], + ), + ( + "Multiple commits squashed via BitBucket PR resolution", + dedent( + """\ + Merged in feat/my-awesome-stuff (pull request #10) + + BUG(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + ENH: implemented searching gizmos by keyword + + DOC(parser): add new parser pattern + + MAINT(cli)!: changed option name + + BREAKING CHANGE: A breaking change description + + Closes: #555 + + invalid non-conventional formatted commit + """ + ), + [ + None, + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MINOR, + "type": "feature", + "descriptions": ["implemented searching gizmos by keyword"], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.NO_RELEASE, + "type": "documentation", + "scope": "parser", + "descriptions": [ + "add new parser pattern", + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MAJOR, + "type": "fix", + "scope": "cli", + "descriptions": [ + "changed option name", + "BREAKING CHANGE: A breaking change description", + "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "invalid non-conventional formatted commit", + ], + "breaking_descriptions": [ + "A breaking change description", + ], + "linked_issues": ("#555",), + "linked_merge_request": "#10", + }, + ], + ), + ] + ], +) +def test_parser_squashed_commit_bitbucket_squash_style( + default_scipy_parser: ScipyCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = ScipyCommitParser( + options=ScipyParserOptions( + **{ + **default_scipy_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via manual Git squash merge", + dedent( + """\ + Squashed commit of the following: + + commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + Author: author + Date: Sun Jan 19 12:05:23 2025 +0000 + + BUG(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + } + ], + ), + ( + "Multiple commits squashed via manual Git squash merge", + dedent( + """\ + Squashed commit of the following: + + commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + Author: author + Date: Sun Jan 19 12:05:23 2025 +0000 + + BUG(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + commit 1f34769bf8352131ad6f4879b8c47becf3c7aa69 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + ENH: implemented searching gizmos by keyword + + commit b2334a64a11ef745a17a2a4034f651e08e8c45a6 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + DOC(parser): add new parser pattern + + commit 5f0292fb5a88c3a46e4a02bec35b85f5228e8e51 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + MAINT(cli): changed option name + + BREAKING CHANGE: A breaking change description + + Closes: #555 + + commit 2f314e7924be161cfbf220d3b6e2a6189a3b5609 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + invalid non-conventional formatted commit + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "release-config", + "descriptions": [ + "some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + }, + { + "bump": LevelBump.MINOR, + "type": "feature", + "descriptions": ["implemented searching gizmos by keyword"], + }, + { + "bump": LevelBump.NO_RELEASE, + "type": "documentation", + "scope": "parser", + "descriptions": [ + "add new parser pattern", + ], + }, + { + "bump": LevelBump.MAJOR, + "type": "fix", + "scope": "cli", + "descriptions": [ + "changed option name", + "BREAKING CHANGE: A breaking change description", + "Closes: #555", + ], + "breaking_descriptions": [ + "A breaking change description", + ], + "linked_issues": ("#555",), + }, + None, + ], + ), + ] + ], +) +def test_parser_squashed_commit_git_squash_style( + default_scipy_parser: ScipyCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = ScipyCommitParser( + options=ScipyParserOptions( + **{ + **default_scipy_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via GitHub PR resolution", + dedent( + """\ + BUG(release-config): some commit subject (#10) + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "release-config", + "descriptions": [ + "some commit subject (#10)", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + ], + ), + ( + "Multiple commits squashed via GitHub PR resolution", + dedent( + """\ + BUG(release-config): some commit subject (#10) + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + * ENH: implemented searching gizmos by keyword + + * DOC(parser): add new parser pattern + + * MAINT(cli)!: changed option name + + BREAKING CHANGE: A breaking change description + + Closes: #555 + + * invalid non-conventional formatted commit + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "release-config", + "descriptions": [ + # TODO: v10 removal of PR number from subject + "some commit subject (#10)", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MINOR, + "type": "feature", + "descriptions": ["implemented searching gizmos by keyword"], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.NO_RELEASE, + "type": "documentation", + "scope": "parser", + "descriptions": [ + "add new parser pattern", + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MAJOR, + "type": "fix", + "scope": "cli", + "descriptions": [ + "changed option name", + "BREAKING CHANGE: A breaking change description", + "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "* invalid non-conventional formatted commit", + ], + "breaking_descriptions": [ + "A breaking change description", + ], + "linked_issues": ("#555",), + "linked_merge_request": "#10", + }, + ], + ), + ] + ], +) +def test_parser_squashed_commit_github_squash_style( + default_scipy_parser: ScipyCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = ScipyCommitParser( + options=ScipyParserOptions( + **{ + **default_scipy_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + + @pytest.mark.parametrize( "message, linked_issues", # TODO: in v10, we will remove the issue reference footers from the descriptions @@ -465,6 +1021,11 @@ def test_parser_return_linked_issues_from_commit_message( linked_issues: Sequence[str], make_commit_obj: MakeCommitObjFn, ): - result = default_scipy_parser.parse(make_commit_obj(message)) + parsed_results = default_scipy_parser.parse(make_commit_obj(message)) + + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert tuple(linked_issues) == result.linked_issues From d991005076ee8749c452461281e8cca38c4c8e81 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 18 Dec 2024 01:04:36 -0700 Subject: [PATCH 03/16] test(parser-emoji): update unit tests for parser return value compatibility --- .../commit_parser/test_emoji.py | 589 +++++++++++++++++- 1 file changed, 581 insertions(+), 8 deletions(-) diff --git a/tests/unit/semantic_release/commit_parser/test_emoji.py b/tests/unit/semantic_release/commit_parser/test_emoji.py index 50c78ccf4..30c52da41 100644 --- a/tests/unit/semantic_release/commit_parser/test_emoji.py +++ b/tests/unit/semantic_release/commit_parser/test_emoji.py @@ -1,17 +1,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Sequence +from textwrap import dedent +from typing import TYPE_CHECKING, Iterable, Sequence import pytest -from semantic_release.commit_parser.token import ParsedCommit +from semantic_release.commit_parser.emoji import EmojiCommitParser, EmojiParserOptions +from semantic_release.commit_parser.token import ParsedCommit, ParseError from semantic_release.enums import LevelBump from tests.const import SUPPORTED_ISSUE_CLOSURE_PREFIXES if TYPE_CHECKING: - from semantic_release.commit_parser.emoji import EmojiCommitParser - from tests.conftest import MakeCommitObjFn @@ -78,8 +78,10 @@ def test_default_emoji_parser( make_commit_obj: MakeCommitObjFn, ): commit = make_commit_obj(commit_message) - result = default_emoji_parser.parse(commit) + parsed_results = default_emoji_parser.parse(commit) + assert isinstance(parsed_results, Iterable) + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert bump is result.bump assert type_ == result.type @@ -124,7 +126,10 @@ def test_parser_return_linked_merge_request_from_commit_message( merge_request_number: str, make_commit_obj: MakeCommitObjFn, ): - result = default_emoji_parser.parse(make_commit_obj(message)) + parsed_results = default_emoji_parser.parse(make_commit_obj(message)) + assert isinstance(parsed_results, Iterable) + + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert merge_request_number == result.linked_merge_request assert subject == result.descriptions[0] @@ -410,11 +415,579 @@ def test_parser_return_linked_issues_from_commit_message( make_commit_obj: MakeCommitObjFn, ): # Setup: Enable parsing of linked issues - default_emoji_parser.options.parse_linked_issues = True + parser = EmojiCommitParser( + options=EmojiParserOptions( + **{ + **default_emoji_parser.options.__dict__, + "parse_linked_issues": True, + } + ) + ) # Action - result = default_emoji_parser.parse(make_commit_obj(message)) + parsed_results = parser.parse(make_commit_obj(message)) + assert isinstance(parsed_results, Iterable) + assert len(parsed_results) == 1 # Evaluate (expected -> actual) + result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) assert tuple(linked_issues) == result.linked_issues + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via BitBucket PR resolution", + dedent( + """\ + Merged in feat/my-awesome-stuff (pull request #10) + + :bug:(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + """ + ), + [ + { + "bump": LevelBump.NO_RELEASE, + "type": "Other", + "descriptions": [ + "Merged in feat/my-awesome-stuff (pull request #10)" + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.PATCH, + "type": ":bug:", + "scope": "release-config", + "descriptions": [ + ":bug:(release-config): some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + ], + ), + ( + "Multiple commits squashed via BitBucket PR resolution", + dedent( + """\ + Merged in feat/my-awesome-stuff (pull request #10) + + :bug:(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + :sparkles: implemented searching gizmos by keyword + + :memo:(parser): add new parser pattern + + :boom::bug: changed option name + + A breaking change description + + Closes: #555 + + invalid non-conventional formatted commit + """ + ), + [ + { + "bump": LevelBump.NO_RELEASE, + "type": "Other", + "descriptions": [ + "Merged in feat/my-awesome-stuff (pull request #10)" + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.PATCH, + "type": ":bug:", + "scope": "release-config", + "descriptions": [ + ":bug:(release-config): some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MINOR, + "type": ":sparkles:", + "descriptions": [ + ":sparkles: implemented searching gizmos by keyword" + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.NO_RELEASE, + "type": ":memo:", + "scope": "parser", + "descriptions": [ + ":memo:(parser): add new parser pattern", + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MAJOR, + "type": ":boom:", + "scope": "", + "descriptions": [ + ":boom::bug: changed option name", + "A breaking change description", + "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "invalid non-conventional formatted commit", + ], + "breaking_descriptions": [ + "A breaking change description", + "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "invalid non-conventional formatted commit", + ], + "linked_issues": ("#555",), + "linked_merge_request": "#10", + }, + ], + ), + ] + ], +) +def test_parser_squashed_commit_bitbucket_squash_style( + default_emoji_parser: EmojiCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = EmojiCommitParser( + options=EmojiParserOptions( + **{ + **default_emoji_parser.options.__dict__, + "parse_squash_commits": True, + "parse_linked_issues": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via manual Git squash merge", + dedent( + """\ + Squashed commit of the following: + + commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + Author: author + Date: Sun Jan 19 12:05:23 2025 +0000 + + :bug:(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": ":bug:", + "scope": "release-config", + "descriptions": [ + ":bug:(release-config): some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + } + ], + ), + ( + "Multiple commits squashed via manual Git squash merge", + dedent( + """\ + Squashed commit of the following: + + commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + Author: author + Date: Sun Jan 19 12:05:23 2025 +0000 + + :bug:(release-config): some commit subject + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + commit 1f34769bf8352131ad6f4879b8c47becf3c7aa69 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + :sparkles: implemented searching gizmos by keyword + + commit b2334a64a11ef745a17a2a4034f651e08e8c45a6 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + :memo:(parser): add new parser pattern + + commit 5f0292fb5a88c3a46e4a02bec35b85f5228e8e51 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + :boom::bug: changed option name + + A breaking change description + + Closes: #555 + + commit 2f314e7924be161cfbf220d3b6e2a6189a3b5609 + Author: author + Date: Sat Jan 18 10:13:53 2025 +0000 + + invalid non-conventional formatted commit + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": ":bug:", + "scope": "release-config", + "descriptions": [ + ":bug:(release-config): some commit subject", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + }, + { + "bump": LevelBump.MINOR, + "type": ":sparkles:", + "descriptions": [ + ":sparkles: implemented searching gizmos by keyword" + ], + }, + { + "bump": LevelBump.NO_RELEASE, + "type": ":memo:", + "scope": "parser", + "descriptions": [ + ":memo:(parser): add new parser pattern", + ], + }, + { + "bump": LevelBump.MAJOR, + "type": ":boom:", + "descriptions": [ + ":boom::bug: changed option name", + "A breaking change description", + "Closes: #555", + ], + "breaking_descriptions": [ + "A breaking change description", + "Closes: #555", + ], + "linked_issues": ("#555",), + }, + { + "bump": LevelBump.NO_RELEASE, + "type": "Other", + "descriptions": ["invalid non-conventional formatted commit"], + }, + ], + ), + ] + ], +) +def test_parser_squashed_commit_git_squash_style( + default_emoji_parser: EmojiCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = EmojiCommitParser( + options=EmojiParserOptions( + **{ + **default_emoji_parser.options.__dict__, + "parse_squash_commits": True, + "parse_linked_issues": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 + + +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Single commit squashed via GitHub PR resolution", + dedent( + """\ + :bug:(release-config): some commit subject (#10) + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": ":bug:", + "scope": "release-config", + "descriptions": [ + # TODO: v10 removal of PR number from subject + ":bug:(release-config): some commit subject (#10)", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + ], + ), + ( + "Multiple commits squashed via GitHub PR resolution", + dedent( + """\ + :bug:(release-config): some commit subject (#10) + + An additional description + + Second paragraph with multiple lines + that will be condensed + + Resolves: #12 + Signed-off-by: author + + * :sparkles: implemented searching gizmos by keyword + + * :memo:(parser): add new parser pattern + + * :boom::bug: changed option name + + A breaking change description + + Closes: #555 + + * invalid non-conventional formatted commit + """ + ), + [ + { + "bump": LevelBump.PATCH, + "type": ":bug:", + "scope": "release-config", + "descriptions": [ + # TODO: v10 removal of PR number from subject + ":bug:(release-config): some commit subject (#10)", + "An additional description", + "Second paragraph with multiple lines that will be condensed", + "Resolves: #12", + "Signed-off-by: author ", + ], + "linked_issues": ("#12",), + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MINOR, + "type": ":sparkles:", + "descriptions": [ + ":sparkles: implemented searching gizmos by keyword" + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.NO_RELEASE, + "type": ":memo:", + "scope": "parser", + "descriptions": [ + ":memo:(parser): add new parser pattern", + ], + "linked_merge_request": "#10", + }, + { + "bump": LevelBump.MAJOR, + "type": ":boom:", + "scope": "", + "descriptions": [ + ":boom::bug: changed option name", + "A breaking change description", + "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "* invalid non-conventional formatted commit", + ], + "breaking_descriptions": [ + "A breaking change description", + "Closes: #555", + "* invalid non-conventional formatted commit", + ], + "linked_issues": ("#555",), + "linked_merge_request": "#10", + }, + ], + ), + ] + ], +) +def test_parser_squashed_commit_github_squash_style( + default_emoji_parser: EmojiCommitParser, + make_commit_obj: MakeCommitObjFn, + commit_message: str, + expected_commit_details: Sequence[dict | None], +): + # Setup: Enable squash commit parsing + parser = EmojiCommitParser( + options=EmojiParserOptions( + **{ + **default_emoji_parser.options.__dict__, + "parse_squash_commits": True, + "parse_linked_issues": True, + } + ) + ) + + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) + + # Validate the results + assert isinstance(parsed_results, Iterable) + assert ( + len(expected_commit_details) == len(parsed_results) + ), f"Expected {len(expected_commit_details)} parsed results, but got {len(parsed_results)}" + + for result, expected in zip(parsed_results, expected_commit_details): + if expected is None: + assert isinstance(result, ParseError) + continue + + assert isinstance(result, ParsedCommit) + # Required + assert expected["bump"] == result.bump + assert expected["type"] == result.type + # Optional + assert expected.get("scope", "") == result.scope + # TODO: v10 change to tuples + assert expected.get("descriptions", []) == result.descriptions + 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 From 04682c04aa860e29f7f9b052be953aebda1b75c6 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 8 Mar 2024 00:03:48 -0500 Subject: [PATCH 04/16] feat(version): parse squashed commits individually adds the functionality to separately parse each commit message within a squashed merge commit to detect combined commit types that could change the version bump --- src/semantic_release/version/algorithm.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index f1fe86253..90face7c8 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -2,6 +2,7 @@ import logging from contextlib import suppress +from functools import reduce from queue import LifoQueue from typing import TYPE_CHECKING, Iterable @@ -346,11 +347,25 @@ def next_version( ) # Step 5. Parse the commits to determine the bump level that should be applied - parsed_levels: set[LevelBump] = { + parsed_levels: set[LevelBump] = { # type: ignore[var-annotated] # too complex for type checkers 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_release), + # Filter out any non-ParsedCommit results (i.e. ParseErrors) + lambda parsed_result: isinstance(parsed_result, ParsedCommit), # type: ignore[arg-type] + reduce( + # Accumulate all parsed results into a single list + lambda accumulated_results, parsed_results: [ + *accumulated_results, + *( + parsed_results + if isinstance(parsed_results, Iterable) + else [parsed_results] # type: ignore[list-item] + ), + ], + # apply the parser to each commit in the history (could return multiple results per commit) + map(commit_parser.parse, commits_since_last_release), + [], + ), ) } From 4ae5be329fc59b3d924c3c24ab9d65b90b1a9728 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 26 Nov 2023 23:21:00 -0500 Subject: [PATCH 05/16] feat(changelog): parse squashed commits individually adds functionality to separately parse each commit message within a squashed merge commit which decouples the commits into their respective type categories in the changelog. --- .../changelog/release_history.py | 122 ++++++++++-------- src/semantic_release/commit_parser/_base.py | 2 +- src/semantic_release/commit_parser/angular.py | 2 +- src/semantic_release/commit_parser/emoji.py | 2 +- src/semantic_release/commit_parser/tag.py | 4 +- 5 files changed, 72 insertions(+), 60 deletions(-) diff --git a/src/semantic_release/changelog/release_history.py b/src/semantic_release/changelog/release_history.py index e50728d4c..16a3e9637 100644 --- a/src/semantic_release/changelog/release_history.py +++ b/src/semantic_release/changelog/release_history.py @@ -102,75 +102,85 @@ def from_git_history( released.setdefault(the_version, release) - # mypy will be happy if we make this an explicit string - commit_message = str(commit.message) - log.info( "parsing commit [%s] %s", commit.hexsha[:8], - commit_message.replace("\n", " ")[:54], - ) - parse_result = commit_parser.parse(commit) - commit_type = ( - "unknown" if isinstance(parse_result, ParseError) else parse_result.type - ) - - has_exclusion_match = any( - pattern.match(commit_message) for pattern in exclude_commit_patterns - ) - - commit_level_bump = ( - LevelBump.NO_RELEASE - if isinstance(parse_result, ParseError) - else parse_result.bump + str(commit.message).replace("\n", " ")[:54], ) + # returns a ParseResult or list of ParseResult objects, + # it is usually one, but we split a commit if a squashed merge is detected + parse_results = commit_parser.parse(commit) + if not isinstance(parse_results, list): + parse_results = [parse_results] + + is_squash_commit = bool(len(parse_results) > 1) + + # iterate through parsed commits to add to changelog definition + for parsed_result in parse_results: + commit_message = str(parsed_result.commit.message) + commit_type = ( + "unknown" + if isinstance(parsed_result, ParseError) + else parsed_result.type + ) + log.debug("commit has type '%s'", commit_type) - # 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 - # commits included, the true reason for a version bump would be missing. - if has_exclusion_match and commit_level_bump == LevelBump.NO_RELEASE: - log.info( - "Excluding commit[%s] %s", - parse_result.short_hash, - commit_message.split("\n", maxsplit=1)[0][:40], + has_exclusion_match = any( + pattern.match(commit_message) for pattern in exclude_commit_patterns ) - continue - if ( - isinstance(parse_result, ParsedCommit) - and not parse_result.include_in_changelog - ): - log.info( - str.join( - " ", - [ - "Excluding commit[%s] (%s) because parser determined", - "it should not included in the changelog", - ], - ), - parse_result.short_hash, - commit_message.replace("\n", " ")[:20], + commit_level_bump = ( + LevelBump.NO_RELEASE + if isinstance(parsed_result, ParseError) + else parsed_result.bump ) - continue - if the_version is None: + # 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 + # commits included, the true reason for a version bump would be missing. + if has_exclusion_match and commit_level_bump == LevelBump.NO_RELEASE: + log.info( + "Excluding %s commit[%s] %s", + "piece of squashed" if is_squash_commit else "", + parsed_result.short_hash, + commit_message.split("\n", maxsplit=1)[0][:20], + ) + continue + + if ( + isinstance(parsed_result, ParsedCommit) + and not parsed_result.include_in_changelog + ): + log.info( + str.join( + " ", + [ + "Excluding commit[%s] because parser determined", + "it should not included in the changelog", + ], + ), + parsed_result.short_hash, + ) + continue + + if the_version is None: + log.info( + "[Unreleased] adding commit[%s] to unreleased '%s'", + parsed_result.short_hash, + commit_type, + ) + unreleased[commit_type].append(parsed_result) + continue + log.info( - "[Unreleased] adding commit[%s] to unreleased '%s'", - parse_result.short_hash, + "[%s] adding commit[%s] to release '%s'", + the_version, + parsed_result.short_hash, commit_type, ) - unreleased[commit_type].append(parse_result) - continue - - log.info( - "[%s] adding commit[%s] to release '%s'", - the_version, - parse_result.short_hash, - commit_type, - ) - released[the_version]["elements"][commit_type].append(parse_result) + released[the_version]["elements"][commit_type].append(parsed_result) return cls(unreleased=unreleased, released=released) diff --git a/src/semantic_release/commit_parser/_base.py b/src/semantic_release/commit_parser/_base.py index d97faa1b8..04d2f56bd 100644 --- a/src/semantic_release/commit_parser/_base.py +++ b/src/semantic_release/commit_parser/_base.py @@ -81,4 +81,4 @@ def get_default_options(self) -> _OPTS: return self.parser_options() # type: ignore[return-value] @abstractmethod - def parse(self, commit: Commit) -> _TT: ... + def parse(self, commit: Commit) -> _TT | list[_TT]: ... diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index c22d80f06..8db3ef5f2 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -263,7 +263,7 @@ def parse_message(self, message: str) -> ParsedMessageResult | None: # mypy/pytest use their own caching directories, for very large commit # histories? # The problem is the cache likely won't be present in CI environments - def parse(self, commit: Commit) -> ParseResult: + def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: """ Attempt to parse the commit message with a regular expression into a ParseResult diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index df8aeba38..42ae4c2d5 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -249,7 +249,7 @@ def parse_message(self, message: str) -> ParsedMessageResult: linked_merge_request=linked_merge_request, ) - def parse(self, commit: Commit) -> ParseResult: + def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: """ Attempt to parse the commit message with a regular expression into a ParseResult diff --git a/src/semantic_release/commit_parser/tag.py b/src/semantic_release/commit_parser/tag.py index 8a400a036..b9a042cc7 100644 --- a/src/semantic_release/commit_parser/tag.py +++ b/src/semantic_release/commit_parser/tag.py @@ -1,5 +1,7 @@ """Legacy commit parser from Python Semantic Release 1.0""" +from __future__ import annotations + import logging import re @@ -41,7 +43,7 @@ class TagCommitParser(CommitParser[ParseResult, TagParserOptions]): def get_default_options() -> TagParserOptions: return TagParserOptions() - def parse(self, commit: Commit) -> ParseResult: + def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: message = str(commit.message) # Attempt to parse the commit message with a regular expression From 51cbe958421c37ffc03960fb294deb82632be3f1 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 19 Jan 2025 22:37:41 -0700 Subject: [PATCH 06/16] refactor(helpers): centralize utility for applying multiple text substitutions --- src/semantic_release/helpers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/semantic_release/helpers.py b/src/semantic_release/helpers.py index 0840169ed..c05635718 100644 --- a/src/semantic_release/helpers.py +++ b/src/semantic_release/helpers.py @@ -13,6 +13,7 @@ from urllib.parse import urlsplit if TYPE_CHECKING: # pragma: no cover + from re import Pattern from typing import Iterable @@ -83,6 +84,15 @@ def sort_numerically( ) +def text_reducer(text: str, filter_pair: tuple[Pattern[str], str]) -> str: + """Reduce function to apply mulitple filters to a string""" + if not text: # abort if the paragraph is empty + return text + + filter_pattern, replacement = filter_pair + return filter_pattern.sub(replacement, text) + + def format_arg(value: Any) -> str: """Helper to format an argument an argument for logging""" if type(value) == str: From 466808665e16eeea4b33680c05c976546e5bfbc9 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 23 Oct 2024 22:55:00 -0600 Subject: [PATCH 07/16] feat(parser-angular): upgrade angular parser to parse squashed commits individually Resolves: #1085 --- src/semantic_release/commit_parser/angular.py | 182 ++++++++++++++++-- src/semantic_release/commit_parser/util.py | 47 ++++- 2 files changed, 213 insertions(+), 16 deletions(-) diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 8db3ef5f2..37aa967a1 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -10,8 +10,10 @@ from functools import reduce from itertools import zip_longest from re import compile as regexp +from textwrap import dedent from typing import TYPE_CHECKING, Tuple +from git.objects.commit import Commit from pydantic.dataclasses import dataclass from semantic_release.commit_parser._base import CommitParser, ParserOptions @@ -21,10 +23,15 @@ ParseError, ParseResult, ) -from semantic_release.commit_parser.util import breaking_re, parse_paragraphs +from semantic_release.commit_parser.util import ( + breaking_re, + deep_copy_commit, + force_str, + parse_paragraphs, +) from semantic_release.enums import LevelBump from semantic_release.errors import InvalidParserOptions -from semantic_release.helpers import sort_numerically +from semantic_release.helpers import sort_numerically, text_reducer if TYPE_CHECKING: # pragma: no cover from git.objects.commit import Commit @@ -90,6 +97,10 @@ class AngularParserOptions(ParserOptions): default_bump_level: LevelBump = LevelBump.NO_RELEASE """The minimum bump level to apply to valid commit message.""" + # TODO: breaking change v10, change default to True + parse_squash_commits: bool = False + """Toggle flag for whether or not to parse squash commits""" + @property def tag_to_level(self) -> dict[str, LevelBump]: """A mapping of commit tags to the level bump they should result in.""" @@ -139,14 +150,23 @@ def __init__(self, options: AngularParserOptions | None = None) -> None: ) ) from err - self.re_parser = regexp( + self.commit_prefix = regexp( str.join( "", [ - r"^" + commit_type_pattern.pattern, + f"^{commit_type_pattern.pattern}", r"(?:\((?P[^\n]+)\))?", # TODO: remove ! support as it is not part of the angular commit spec (its part of conventional commits spec) r"(?P!)?:\s+", + ], + ) + ) + + self.re_parser = regexp( + str.join( + "", + [ + self.commit_prefix.pattern, r"(?P[^\n]+)", r"(?:\n\n(?P.+))?", # commit body ], @@ -168,6 +188,42 @@ def __init__(self, options: AngularParserOptions | None = None) -> None: ), flags=re.MULTILINE | re.IGNORECASE, ) + self.filters = { + "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"), + "git-header-commit": ( + regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-author": ( + regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-date": ( + regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-squash-heading": ( + regexp( + r"^[\t ]*Squashed commit of the following:.*$\n?", + flags=re.MULTILINE, + ), + "", + ), + "git-squash-commit-prefix": ( + regexp( + str.join( + "", + [ + r"^(?:[\t ]*[*-][\t ]+|[\t ]+)?", # bullet points or indentation + commit_type_pattern.pattern + r"\b", # prior to commit type + ], + ), + flags=re.MULTILINE, + ), + # move commit type to the start of the line + r"\1", + ), + } @staticmethod def get_default_options() -> AngularParserOptions: @@ -213,7 +269,7 @@ def parse_message(self, message: str) -> ParsedMessageResult | None: return None parsed_break = parsed.group("break") - parsed_scope = parsed.group("scope") + parsed_scope = parsed.group("scope") or "" parsed_subject = parsed.group("subject") parsed_text = parsed.group("text") parsed_type = parsed.group("type") @@ -259,24 +315,120 @@ def parse_message(self, message: str) -> ParsedMessageResult | None: linked_merge_request=linked_merge_request, ) + def parse_commit(self, commit: Commit) -> ParseResult: + if not (parsed_msg_result := self.parse_message(force_str(commit.message))): + return _logged_parse_error( + commit, + f"Unable to parse commit message: {commit.message!r}", + ) + + return ParsedCommit.from_parsed_message_result(commit, parsed_msg_result) + # Maybe this can be cached as an optimization, similar to how # mypy/pytest use their own caching directories, for very large commit # histories? # The problem is the cache likely won't be present in CI environments def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: """ - Attempt to parse the commit message with a regular expression into a - ParseResult + Parse a commit message + + If the commit message is a squashed merge commit, it will be split into + multiple commits, each of which will be parsed separately. Single commits + will be returned as a list of a single ParseResult. """ - if not (pmsg_result := self.parse_message(str(commit.message))): - return _logged_parse_error( - commit, f"Unable to parse commit message: {commit.message!r}" + separate_commits: list[Commit] = ( + self.unsquash_commit(commit) + if self.options.parse_squash_commits + else [commit] + ) + + # Parse each commit individually if there were more than one + return list(map(self.parse_commit, separate_commits)) + + def unsquash_commit(self, commit: Commit) -> list[Commit]: + # GitHub EXAMPLE: + # feat(changelog): add autofit_text_width filter to template environment (#1062) + # + # This change adds an equivalent style formatter that can apply a text alignment + # to a maximum width and also maintain an indent over paragraphs of text + # + # * docs(changelog-templates): add definition & usage of autofit_text_width template filter + # + # * test(changelog-context): add test cases to check autofit_text_width filter use + # + # `git merge --squash` EXAMPLE: + # Squashed commit of the following: + # + # commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + # Author: codejedi365 + # Date: Sun Oct 13 12:05:23 2024 -0600 + # + # feat(release-config): some commit subject + # + + # Return a list of artificial commits (each with a single commit message) + return [ + # create a artificial commit object (copy of original but with modified message) + Commit( + **{ + **deep_copy_commit(commit), + "message": commit_msg, + } ) + for commit_msg in self.unsquash_commit_message(force_str(commit.message)) + ] or [commit] + + def unsquash_commit_message(self, message: str) -> list[str]: + normalized_message = message.replace("\r", "").strip() + + # split by obvious separate commits (applies to manual git squash merges) + obvious_squashed_commits = self.filters["git-header-commit"][0].split( + normalized_message + ) - logger.debug( - "commit %s introduces a %s level_bump", - commit.hexsha[:8], - pmsg_result.bump, + separate_commit_msgs: list[str] = reduce( + lambda all_msgs, msgs: all_msgs + msgs, + map(self._find_squashed_commits_in_str, obvious_squashed_commits), + [], ) - return ParsedCommit.from_parsed_message_result(commit, pmsg_result) + return separate_commit_msgs + + def _find_squashed_commits_in_str(self, message: str) -> list[str]: + separate_commit_msgs: list[str] = [] + current_msg = "" + + for paragraph in filter(None, message.strip().split("\n\n")): + # Apply filters to normalize the paragraph + clean_paragraph = reduce(text_reducer, self.filters.values(), paragraph) + + # remove any filtered (and now empty) paragraphs (ie. the git headers) + if not clean_paragraph.strip(): + continue + + # Check if the paragraph is the start of a new angular commit + if not self.commit_prefix.search(clean_paragraph): + if not separate_commit_msgs and not current_msg: + # if there are no separate commit messages and no current message + # then this is the first commit message + current_msg = dedent(clean_paragraph) + continue + + # append the paragraph as part of the previous commit message + if current_msg: + current_msg += f"\n\n{dedent(clean_paragraph)}" + # else: drop the paragraph + continue + + # Since we found the start of the new commit, store any previous commit + # message separately and start the new commit message + if current_msg: + separate_commit_msgs.append(current_msg) + + current_msg = clean_paragraph + + # Store the last commit message (if its not empty) + if current_msg: + separate_commit_msgs.append(current_msg) + + return separate_commit_msgs diff --git a/src/semantic_release/commit_parser/util.py b/src/semantic_release/commit_parser/util.py index 60ed63b2f..e6d768428 100644 --- a/src/semantic_release/commit_parser/util.py +++ b/src/semantic_release/commit_parser/util.py @@ -1,5 +1,7 @@ from __future__ import annotations +from contextlib import suppress +from copy import deepcopy from functools import reduce from re import MULTILINE, compile as regexp from typing import TYPE_CHECKING @@ -11,7 +13,9 @@ if TYPE_CHECKING: # pragma: no cover from re import Pattern - from typing import TypedDict + from typing import Any, TypedDict + + from git import Commit class RegexReplaceDef(TypedDict): pattern: Pattern @@ -74,3 +78,44 @@ def parse_paragraphs(text: str) -> list[str]: ], ) ) + + +def force_str(msg: str | bytes | bytearray | memoryview) -> str: + # This shouldn't be a thing but typing is being weird around what + # git.commit.message returns and the memoryview type won't go away + message = msg.tobytes() if isinstance(msg, memoryview) else msg + return ( + message.decode("utf-8") + if isinstance(message, (bytes, bytearray)) + else str(message) + ) + + +def deep_copy_commit(commit: Commit) -> dict[str, Any]: + keys = [ + "repo", + "binsha", + "author", + "authored_date", + "committer", + "committed_date", + "message", + "tree", + "parents", + "encoding", + "gpgsig", + "author_tz_offset", + "committer_tz_offset", + ] + kwargs = {} + for key in keys: + with suppress(ValueError): + if hasattr(commit, key) and (value := getattr(commit, key)) is not None: + if key in ["parents", "repo", "tree"]: + # These tend to have circular references so don't deepcopy them + kwargs[key] = value + continue + + kwargs[key] = deepcopy(value) + + return kwargs From a3df10aa0d11ff828d2e284ff89fc459ef22c666 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 23 Oct 2024 23:26:59 -0600 Subject: [PATCH 08/16] feat(parser-angular): apply PR/MR numbers to all parsed commits from a squash merge --- src/semantic_release/commit_parser/angular.py | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 37aa967a1..511d73a38 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -343,7 +343,57 @@ def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: ) # Parse each commit individually if there were more than one - return list(map(self.parse_commit, separate_commits)) + parsed_commits: list[ParseResult] = list( + map(self.parse_commit, separate_commits) + ) + + def add_linked_merge_request( + parsed_result: ParseResult, mr_number: str + ) -> ParseResult: + return ( + parsed_result + if not isinstance(parsed_result, ParsedCommit) + else ParsedCommit( + **{ + **parsed_result._asdict(), + "linked_merge_request": mr_number, + } + ) + ) + + # TODO: improve this for other VCS systems other than GitHub & BitBucket + # Github works as the first commit in a squash merge commit has the PR number + # appended to the first line of the commit message + lead_commit = next(iter(parsed_commits)) + + if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request: + # If the first commit has linked merge requests, assume all commits + # are part of the same PR and add the linked merge requests to all + # parsed commits + parsed_commits = [ + lead_commit, + *map( + lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc] + add_linked_merge_request(parsed_result, mr) + ), + parsed_commits[1:], + ), + ] + + elif isinstance(lead_commit, ParseError) and ( + mr_match := self.mr_selector.search(force_str(lead_commit.message)) + ): + # Handle BitBucket Squash Merge Commits (see #1085), which have non angular commit + # format but include the PR number in the commit subject that we want to extract + linked_merge_request = mr_match.group("mr_number") + + # apply the linked MR to all commits + parsed_commits = [ + add_linked_merge_request(parsed_result, linked_merge_request) + for parsed_result in parsed_commits + ] + + return parsed_commits def unsquash_commit(self, commit: Commit) -> list[Commit]: # GitHub EXAMPLE: From 7e6464afbc7979b4b06bf1d951fc61a4a6e4966c Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 19 Jan 2025 16:24:38 -0700 Subject: [PATCH 09/16] feat(parser-emoji): add functionality to interpret scopes from gitmoji commit messages --- src/semantic_release/commit_parser/emoji.py | 22 ++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index 42ae4c2d5..c78dd1ee3 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -133,7 +133,7 @@ def __init__(self, options: EmojiParserOptions | None = None) -> None: emojis_in_precedence_order = list(self.options.tag_to_level.keys())[::-1] try: - self.emoji_selector = regexp( + highest_emoji_pattern = regexp( r"(?P%s)" % str.join("|", emojis_in_precedence_order) ) except re.error as err: @@ -148,6 +148,16 @@ def __init__(self, options: EmojiParserOptions | None = None) -> None: ) ) from err + self.emoji_selector = regexp( + str.join( + "", + [ + f"^{highest_emoji_pattern.pattern}", + r"(?:\((?P[^\n]+)\))?", + ] + ) + ) + # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123) self.mr_selector = regexp( r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$" @@ -210,11 +220,9 @@ def parse_message(self, message: str) -> ParsedMessageResult: # subject = self.mr_selector.sub("", subject).strip() # Search for emoji of the highest importance in the subject - primary_emoji = ( - match.group("type") - if (match := self.emoji_selector.search(subject)) - else "Other" - ) + match = self.emoji_selector.search(subject) + primary_emoji = match.group("type") if match else "Other" + parsed_scope = (match.group("scope") if match else None) or "" level_bump = self.options.tag_to_level.get( primary_emoji, self.options.default_bump_level @@ -236,7 +244,7 @@ def parse_message(self, message: str) -> ParsedMessageResult: bump=level_bump, type=primary_emoji, category=primary_emoji, - scope="", # TODO: add scope support + scope=parsed_scope, # TODO: breaking change v10, removes breaking change footers from descriptions # descriptions=( # descriptions[:1] if level_bump is LevelBump.MAJOR else descriptions From 3c8a2756865849144b3c440fb1f43bdd012f2704 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 18 Dec 2024 00:35:18 -0700 Subject: [PATCH 10/16] feat(parser-emoji): upgrade emoji parser to parse squashed commits individually --- src/semantic_release/commit_parser/emoji.py | 213 ++++++++++++++++++-- 1 file changed, 196 insertions(+), 17 deletions(-) diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index c78dd1ee3..c26b2bb7d 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -7,6 +7,7 @@ from functools import reduce from itertools import zip_longest from re import compile as regexp +from textwrap import dedent from typing import Tuple from git.objects.commit import Commit @@ -18,10 +19,14 @@ ParsedMessageResult, ParseResult, ) -from semantic_release.commit_parser.util import parse_paragraphs +from semantic_release.commit_parser.util import ( + deep_copy_commit, + force_str, + parse_paragraphs, +) from semantic_release.enums import LevelBump from semantic_release.errors import InvalidParserOptions -from semantic_release.helpers import sort_numerically +from semantic_release.helpers import sort_numerically, text_reducer logger = logging.getLogger(__name__) @@ -75,11 +80,6 @@ class EmojiParserOptions(ParserOptions): default_bump_level: LevelBump = LevelBump.NO_RELEASE """The minimum bump level to apply to valid commit message.""" - @property - def tag_to_level(self) -> dict[str, LevelBump]: - """A mapping of commit tags to the level bump they should result in.""" - return self._tag_to_level - parse_linked_issues: bool = False """ Whether to parse linked issues from the commit message. @@ -93,6 +93,15 @@ def tag_to_level(self) -> dict[str, LevelBump]: a whitespace separator. """ + # TODO: breaking change v10, change default to True + parse_squash_commits: bool = False + """Toggle flag for whether or not to parse squash commits""" + + @property + def tag_to_level(self) -> dict[str, LevelBump]: + """A mapping of commit tags to the level bump they should result in.""" + return self._tag_to_level + def __post_init__(self) -> None: self._tag_to_level: dict[str, LevelBump] = { str(tag): level @@ -153,8 +162,8 @@ def __init__(self, options: EmojiParserOptions | None = None) -> None: "", [ f"^{highest_emoji_pattern.pattern}", - r"(?:\((?P[^\n]+)\))?", - ] + r"(?:\((?P[^)]+)\))?:?", + ], ) ) @@ -174,6 +183,44 @@ def __init__(self, options: EmojiParserOptions | None = None) -> None: flags=re.MULTILINE | re.IGNORECASE, ) + self.filters = { + "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"), + "git-header-commit": ( + regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-author": ( + regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-date": ( + regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-squash-heading": ( + regexp( + r"^[\t ]*Squashed commit of the following:.*$\n?", + flags=re.MULTILINE, + ), + "", + ), + "git-squash-commit-prefix": ( + regexp( + str.join( + "", + [ + r"^(?:[\t ]*[*-][\t ]+|[\t ]+)?", # bullet points or indentation + highest_emoji_pattern.pattern + + r"(\W)", # prior to commit type + ], + ), + flags=re.MULTILINE, + ), + # move commit type to the start of the line + r"\1\2", + ), + } + @staticmethod def get_default_options() -> EmojiParserOptions: return EmojiParserOptions() @@ -257,17 +304,149 @@ def parse_message(self, message: str) -> ParsedMessageResult: linked_merge_request=linked_merge_request, ) + def parse_commit(self, commit: Commit) -> ParseResult: + return ParsedCommit.from_parsed_message_result( + commit, self.parse_message(force_str(commit.message)) + ) + def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: """ - Attempt to parse the commit message with a regular expression into a - ParseResult + Parse a commit message + + If the commit message is a squashed merge commit, it will be split into + multiple commits, each of which will be parsed separately. Single commits + will be returned as a list of a single ParseResult. """ - pmsg_result = self.parse_message(str(commit.message)) + separate_commits: list[Commit] = ( + self.unsquash_commit(commit) + if self.options.parse_squash_commits + else [commit] + ) + + # Parse each commit individually if there were more than one + parsed_commits: list[ParseResult] = list( + map(self.parse_commit, separate_commits) + ) + + def add_linked_merge_request( + parsed_result: ParseResult, mr_number: str + ) -> ParseResult: + return ( + parsed_result + if not isinstance(parsed_result, ParsedCommit) + else ParsedCommit( + **{ + **parsed_result._asdict(), + "linked_merge_request": mr_number, + } + ) + ) + + # TODO: improve this for other VCS systems other than GitHub & BitBucket + # Github works as the first commit in a squash merge commit has the PR number + # appended to the first line of the commit message + lead_commit = next(iter(parsed_commits)) + + if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request: + # If the first commit has linked merge requests, assume all commits + # are part of the same PR and add the linked merge requests to all + # parsed commits + parsed_commits = [ + lead_commit, + *map( + lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc] + add_linked_merge_request(parsed_result, mr) + ), + parsed_commits[1:], + ), + ] + + return parsed_commits + + def unsquash_commit(self, commit: Commit) -> list[Commit]: + # GitHub EXAMPLE: + # ✨(changelog): add autofit_text_width filter to template environment (#1062) + # + # This change adds an equivalent style formatter that can apply a text alignment + # to a maximum width and also maintain an indent over paragraphs of text + # + # * 🌐 Support Japanese language + # + # * ✅(changelog-context): add test cases to check autofit_text_width filter use + # + # `git merge --squash` EXAMPLE: + # Squashed commit of the following: + # + # commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + # Author: codejedi365 + # Date: Sun Oct 13 12:05:23 2024 -0000 + # + # ⚡️ (homepage): Lazyload home screen images + # + # + # Return a list of artificial commits (each with a single commit message) + return [ + # create a artificial commit object (copy of original but with modified message) + Commit( + **{ + **deep_copy_commit(commit), + "message": commit_msg, + } + ) + for commit_msg in self.unsquash_commit_message(force_str(commit.message)) + ] or [commit] - logger.debug( - "commit %s introduces a %s level_bump", - commit.hexsha[:8], - pmsg_result.bump, + def unsquash_commit_message(self, message: str) -> list[str]: + normalized_message = message.replace("\r", "").strip() + + # split by obvious separate commits (applies to manual git squash merges) + obvious_squashed_commits = self.filters["git-header-commit"][0].split( + normalized_message + ) + + separate_commit_msgs: list[str] = reduce( + lambda all_msgs, msgs: all_msgs + msgs, + map(self._find_squashed_commits_in_str, obvious_squashed_commits), + [], ) - return ParsedCommit.from_parsed_message_result(commit, pmsg_result) + return separate_commit_msgs + + def _find_squashed_commits_in_str(self, message: str) -> list[str]: + separate_commit_msgs: list[str] = [] + current_msg = "" + + for paragraph in filter(None, message.strip().split("\n\n")): + # Apply filters to normalize the paragraph + clean_paragraph = reduce(text_reducer, self.filters.values(), paragraph) + + # remove any filtered (and now empty) paragraphs (ie. the git headers) + if not clean_paragraph.strip(): + continue + + # Check if the paragraph is the start of a new angular commit + if not self.emoji_selector.search(clean_paragraph): + if not separate_commit_msgs and not current_msg: + # if there are no separate commit messages and no current message + # then this is the first commit message + current_msg = dedent(clean_paragraph) + continue + + # append the paragraph as part of the previous commit message + if current_msg: + current_msg += f"\n\n{dedent(clean_paragraph)}" + # else: drop the paragraph + continue + + # Since we found the start of the new commit, store any previous commit + # message separately and start the new commit message + if current_msg: + separate_commit_msgs.append(current_msg) + + current_msg = clean_paragraph + + # Store the last commit message (if its not empty) + if current_msg: + separate_commit_msgs.append(current_msg) + + return separate_commit_msgs From c5d364b27238c1c28c9475925ff6e8cc780f1d64 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 22 Jan 2025 22:59:55 -0500 Subject: [PATCH 11/16] test(fixtures): adjust parser for squashed commit defs --- tests/fixtures/git_repo.py | 113 +++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 0656ec3e2..85a3951c5 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -13,6 +13,12 @@ from git import Actor, Repo from semantic_release.cli.config import ChangelogOutputFormat +from semantic_release.commit_parser.angular import ( + AngularCommitParser, + AngularParserOptions, +) +from semantic_release.commit_parser.emoji import EmojiCommitParser, EmojiParserOptions +from semantic_release.commit_parser.scipy import ScipyCommitParser, ScipyParserOptions from semantic_release.version.version import Version import tests.conftest @@ -46,9 +52,6 @@ from typing_extensions import NotRequired - from semantic_release.commit_parser.angular import AngularCommitParser - from semantic_release.commit_parser.emoji import EmojiCommitParser - from semantic_release.commit_parser.scipy import ScipyCommitParser from semantic_release.hvcs import HvcsBase from semantic_release.hvcs.bitbucket import Bitbucket from semantic_release.hvcs.gitea import Gitea @@ -375,6 +378,9 @@ def __call__( self, build_definition: Sequence[RepoActions], key: str ) -> Any: ... + class SeparateSquashedCommitDefFn(Protocol): + def __call__(self, squashed_commit_def: CommitDef) -> list[CommitDef]: ... + @pytest.fixture(scope="session") def deps_files_4_example_git_project( @@ -980,6 +986,94 @@ def _build_configured_base_repo( # noqa: C901 return _build_configured_base_repo +@pytest.fixture(scope="session") +def separate_squashed_commit_def( + default_angular_parser: AngularCommitParser, + default_emoji_parser: EmojiCommitParser, + default_scipy_parser: ScipyCommitParser, +) -> SeparateSquashedCommitDefFn: + message_parsers: dict[ + CommitConvention, AngularCommitParser | EmojiCommitParser | ScipyCommitParser + ] = { + "angular": AngularCommitParser( + options=AngularParserOptions( + **{ + **default_angular_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ), + "emoji": EmojiCommitParser( + options=EmojiParserOptions( + **{ + **default_emoji_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ), + "scipy": ScipyCommitParser( + options=ScipyParserOptions( + **{ + **default_scipy_parser.options.__dict__, + "parse_squash_commits": True, + } + ) + ), + } + + def _separate_squashed_commit_def( + squashed_commit_def: CommitDef, + ) -> list[CommitDef]: + commit_type: CommitConvention = "angular" + for parser_name, parser in message_parsers.items(): + if squashed_commit_def["type"] in parser.options.allowed_tags: + commit_type = parser_name + + parser = message_parsers[commit_type] + if not hasattr(parser, "unsquash_commit_message"): + return [squashed_commit_def] + + unsquashed_messages = parser.unsquash_commit_message( + message=squashed_commit_def["msg"] + ) + + return [ + { + "msg": squashed_message, + "type": parsed_result.type, + "category": parsed_result.category, + "desc": str.join( + "\n\n", + ( + [ + # Strip out any MR references (since v9 doesn't) to prep for changelog generatro + # TODO: remove in v10, as the parser will remove the MR reference + str.join( + "(", parsed_result.descriptions[0].split("(")[:-1] + ).strip(), + *parsed_result.descriptions[1:], + ] + if parsed_result.linked_merge_request + else [*parsed_result.descriptions] + ), + ), + "brking_desc": str.join("\n\n", parsed_result.breaking_descriptions), + "scope": parsed_result.scope, + "mr": parsed_result.linked_merge_request or squashed_commit_def["mr"], + "sha": squashed_commit_def["sha"], + "include_in_changelog": True, + "datetime": squashed_commit_def.get("datetime", ""), + } + for parsed_result, squashed_message in iter( + (parser.parse_message(squashed_msg), squashed_msg) + for squashed_msg in unsquashed_messages + ) + if parsed_result is not None + ] + + return _separate_squashed_commit_def + + @pytest.fixture(scope="session") def convert_commit_spec_to_commit_def( get_commit_def_of_angular_commit: GetCommitDefFn, @@ -1037,6 +1131,7 @@ def build_repo_from_definition( # noqa: C901, its required and its just test co create_merge_commit: CreateMergeCommitFn, simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, simulate_default_changelog_creation: SimulateDefaultChangelogCreationFn, + separate_squashed_commit_def: SeparateSquashedCommitDefFn, ) -> BuildRepoFromDefinitionFn: def expand_repo_construction_steps( acc: Sequence[RepoActions], step: RepoActions @@ -1193,7 +1288,11 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c strategy_option=squash_def["strategy_option"], ) if squash_def["commit_def"]["include_in_changelog"]: - current_commits.append(squash_def["commit_def"]) + current_commits.extend( + separate_squashed_commit_def( + squashed_commit_def=squash_def["commit_def"], + ) + ) elif action == RepoActionStep.GIT_MERGE: this_step: RepoActionGitMerge = step_result # type: ignore[assignment] @@ -1468,7 +1567,8 @@ def build_version_entry_markdown( ) # Add commits to section - section_bullets.append(commit_cl_desc) + if commit_cl_desc not in section_bullets: + section_bullets.append(commit_cl_desc) version_entry.extend(sorted(section_bullets)) @@ -1580,7 +1680,8 @@ def build_version_entry_restructured_text( ) # Add commits to section - section_bullets.append(commit_cl_desc) + if commit_cl_desc not in section_bullets: + section_bullets.append(commit_cl_desc) version_entry.extend(sorted(section_bullets)) From 05dcfa4f0e9e2ff206192a1b5547de898389f5db Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 22 Jan 2025 23:01:00 -0500 Subject: [PATCH 12/16] test(fixtures): change config of github flow repo to parse squash commits --- tests/fixtures/repos/github_flow/repo_w_default_release.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/repos/github_flow/repo_w_default_release.py b/tests/fixtures/repos/github_flow/repo_w_default_release.py index 63e0d6f1b..3e572499b 100644 --- a/tests/fixtures/repos/github_flow/repo_w_default_release.py +++ b/tests/fixtures/repos/github_flow/repo_w_default_release.py @@ -135,6 +135,7 @@ def _get_repo_from_defintion( "prerelease": False, }, "tool.semantic_release.allow_zero_version": False, + "tool.semantic_release.commit_parser_options.parse_squash_commits": True, **(extra_configs or {}), }, }, @@ -244,7 +245,7 @@ def _get_repo_from_defintion( }, { "angular": "docs(cli): add cli documentation", - "emoji": ":books: add cli documentation", + "emoji": ":memo: add cli documentation", "scipy": "DOC: add cli documentation", "datetime": next(commit_timestamp_gen), }, From 82802b7e1a5ff0087300a7ce6f1971c84b42c6a9 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 22 Jan 2025 23:01:50 -0500 Subject: [PATCH 13/16] test(fixtures): add fixture to create gitlab formatted merge commit --- tests/fixtures/git_repo.py | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 85a3951c5..8a99ba261 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -211,6 +211,16 @@ def __call__(self, branch_name: str, tgt_branch_name: str) -> str: ... class FormatGitHubMergeCommitMsgFn(Protocol): def __call__(self, pr_number: int, branch_name: str) -> str: ... + class FormatGitLabMergeCommitMsgFn(Protocol): + def __call__( + self, + mr_title: str, + mr_number: int, + source_branch: str, + target_branch: str, + closed_issues: list[str], + ) -> str: ... + class CreateMergeCommitFn(Protocol): def __call__( self, @@ -611,6 +621,48 @@ def _format_merge_commit_msg_git(pr_number: int, branch_name: str) -> str: return _format_merge_commit_msg_git +@pytest.fixture(scope="session") +def format_merge_commit_msg_gitlab() -> FormatGitLabMergeCommitMsgFn: + def _format_merge_commit_msg( + mr_title: str, + mr_number: int, + source_branch: str, + target_branch: str, + closed_issues: list[str], + ) -> str: + """REF: https://docs.gitlab.com/17.8/ee/user/project/merge_requests/commit_templates.html""" + reference = f"{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}!{mr_number}" + issue_statement = ( + "" + if not closed_issues + else str.join( + " ", + [ + "Closes", + str.join( + " and ", [str.join(", ", closed_issues[:-1]), closed_issues[-1]] + ) + if len(closed_issues) > 1 + else closed_issues[0], + ], + ) + ) + return str.join( + "\n\n", + filter( + None, + [ + f"Merge branch '{source_branch}' into '{target_branch}'", + f"{mr_title}", + f"{issue_statement}", + f"See merge request {reference}", + ], + ), + ) + + return _format_merge_commit_msg + + @pytest.fixture(scope="session") def format_squash_commit_msg_git(commit_author: Actor) -> FormatGitSquashCommitMsgFn: def _format_squash_commit_msg_git( From 6d66013e06104d965b82293954c54768fe7f6a40 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 24 Jan 2025 00:33:40 -0500 Subject: [PATCH 14/16] refactor(parser-scipy): standardize all category spelling applied to commits --- src/semantic_release/commit_parser/emoji.py | 2 +- src/semantic_release/commit_parser/scipy.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index c26b2bb7d..5b8479f18 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -66,7 +66,7 @@ class EmojiParserOptions(ParserOptions): ) """Commit-type prefixes that should result in a patch release bump.""" - other_allowed_tags: Tuple[str, ...] = () + other_allowed_tags: Tuple[str, ...] = (":memo:", ":checkmark:") """Commit-type prefixes that are allowed but do not result in a version bump.""" allowed_tags: Tuple[str, ...] = ( diff --git a/src/semantic_release/commit_parser/scipy.py b/src/semantic_release/commit_parser/scipy.py index 5dd82eead..6234cfdf3 100644 --- a/src/semantic_release/commit_parser/scipy.py +++ b/src/semantic_release/commit_parser/scipy.py @@ -74,21 +74,21 @@ def _logged_parse_error(commit: Commit, error: str) -> ParseError: tag_to_section = { "API": "breaking", - "BENCH": "None", + "BENCH": "none", "BLD": "fix", "BUG": "fix", "DEP": "breaking", - "DEV": "None", + "DEV": "none", "DOC": "documentation", "ENH": "feature", "MAINT": "fix", - "REV": "Other", - "STY": "None", - "TST": "None", - "REL": "None", + "REV": "other", + "STY": "none", + "TST": "none", + "REL": "none", # strictly speaking not part of the standard "FEAT": "feature", - "TEST": "None", + "TEST": "none", } From 76aefc1404fbd842044cd8ca521371912d63a46b Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 25 Jan 2025 19:23:10 -0500 Subject: [PATCH 15/16] docs(commit-parsing): add description for squash commit evaluation option of default parsers --- docs/commit_parsing.rst | 107 ++++++++++++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 20 deletions(-) diff --git a/docs/commit_parsing.rst b/docs/commit_parsing.rst index fe1d3f376..1cb17a886 100644 --- a/docs/commit_parsing.rst +++ b/docs/commit_parsing.rst @@ -108,13 +108,13 @@ logic in relation to how PSR's core features: message. If no issue numbers are found, the parser will return an empty tuple. *Feature available in v9.15.0+.* -**Limitations:** +- **Squash Commit Evaluation**: This parser implements PSR's + :ref:`commit_parser-builtin-squash_commit_evaluation` to identify and extract each commit + message as a separate commit message within a single squashed commit. You can toggle this + feature on/off via the :ref:`config-commit_parser_options` setting. *Feature available in + v9.17.0+.* -- Squash commits are not currently supported. This means that the level bump for a squash - commit is only determined by the subject line of the squash commit. Our default changelog - template currently writes out the entire commit message body in the changelog in order to - provide the full detail of the changes. Track the implementation of this feature with - the issues `#733`_, `#1085`_, and `PR#1112`_. +**Limitations**: - Commits with the ``revert`` type are not currently supported. Track the implementation of this feature in the issue `#402`_. @@ -179,6 +179,12 @@ how PSR's core features: enabled by setting the configuration option ``commit_parser_options.parse_linked_issues`` to ``true``. *Feature available in v9.15.0+.* +- **Squash Commit Evaluation**: This parser implements PSR's + :ref:`commit_parser-builtin-squash_commit_evaluation` to identify and extract each commit + message as a separate commit message within a single squashed commit. You can toggle this + feature on/off via the :ref:`config-commit_parser_options` setting. *Feature available in + v9.17.0+.* + If no commit parser options are provided via the configuration, the parser will use PSR's built-in :py:class:`defaults `. @@ -304,6 +310,72 @@ return an empty tuple. ---- +.. _commit_parser-builtin-squash_commit_evaluation: + +Common Squash Commit Evaluation +""""""""""""""""""""""""""""""" + +*Introduced in v9.17.0* + +All of the PSR built-in parsers implement common squash commit evaluation logic to identify +and extract individual commit messages from a single squashed commit. The parsers will +look for common squash commit delimiters and multiple matches of the commit message +format to identify each individual commit message that was squashed. The parsers will +return a list containing each commit message as a separate commit object. Squashed commits +will be evaluated individually for both the level bump and changelog generation. If no +squash commits are found, a list with the single commit object will be returned. + +Currently, PSR has been tested against GitHub, BitBucket, and official ``git`` squash +merge commmit messages. GitLab does not have a default template for squash commit messages +but can be customized per project or server. If you are using GitLab, you will need to +ensure that the squash commit message format is similar to the example below. + +**Example**: + +*The following example will extract three separate commit messages from a single GitHub +formatted squash commit message of conventional commit style:* + +.. code-block:: text + + feat(config): add new config option (#123) + + * refactor(config): change the implementation of config loading + + * docs(configuration): defined new config option for the project + +When parsed with the default angular parser with squash commits toggled on, the version +bump will be determined by the highest level bump of the three commits (in this case, a +minor bump because of the feature commit) and the release notes would look similar to +the following: + +.. code-block:: markdown + + ## Features + + - **config**: add new config option (#123) + + ## Documentation + + - **configuration**: defined new config option for the project (#123) + + ## Refactoring + + - **config**: change the implementation of config loading (#123) + +Merge request numbers and commit hash values will be the same across all extracted +commits. Additionally, any :ref:`config-changelog-exclude_commit_patterns` will be +applied individually to each extracted commit so if you are have an exclusion match +for ignoring ``refactor`` commits, the second commit in the example above would be +excluded from the changelog. + +.. important:: + When squash commit evaluation is enabled, if you squashed a higher level bump commit + into the body of a lower level bump commit, the higher level bump commit will be + evaluated as the level bump for the entire squashed commit. This includes breaking + change descriptions. + +---- + .. _commit_parser-builtin-customization: Customization @@ -429,28 +501,23 @@ available. .. _catching exceptions in Python is slower: https://docs.python.org/3/faq/design.html#how-fast-are-exceptions .. _namedtuple: https://docs.python.org/3/library/typing.html#typing.NamedTuple -.. _commit-parsing-parser-options: +.. _commit_parser-parser-options: Parser Options """""""""""""" -To provide options to the commit parser which is configured in the :ref:`configuration file -`, Python Semantic Release includes a -:py:class:`ParserOptions ` -class. Each parser built into Python Semantic Release has a corresponding "options" class, which -subclasses :py:class:`ParserOptions `. - -The configuration in :ref:`commit_parser_options ` is passed to the -"options" class which is specified by the configured :ref:`commit_parser ` - -more information on how this is specified is below. +When writing your own parser, you should accompany the parser with an "options" class +which accepts the appropriate keyword arguments. This class' ``__init__`` method should +store the values that are needed for parsing appropriately. Python Semantic Release will +pass any configuration options from the configuration file's +:ref:`commit_parser_options `, into your custom parser options +class. To ensure that the configuration options are passed correctly, the options class +should inherit from the +:py:class:`ParserOptions ` class. The "options" class is used to validate the options which are configured in the repository, and to provide default values for these options where appropriate. -If you are writing your own parser, you should accompany it with an "options" class -which accepts the appropriate keyword arguments. This class' ``__init__`` method should -store the values that are needed for parsing appropriately. - .. _commit-parsing-commit-parsers: Commit Parsers From 369b7541b67516f590b7e27e008a283fa616a797 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 25 Jan 2025 19:29:17 -0500 Subject: [PATCH 16/16] docs(configuration): update the `commit_parser_options` setting description --- docs/configuration.rst | 62 ++++-------------------------------------- 1 file changed, 5 insertions(+), 57 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index fac883479..884a28915 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -811,66 +811,14 @@ For more information see :ref:`commit-parsing`. **Type:** ``dict[str, Any]`` -These options are passed directly to the ``parser_options`` method of -:ref:`the commit parser `, without validation -or transformation. +This set of options are passed directly to the commit parser class specified in +:ref:`the commit parser ` configuration option. -For more information, see :ref:`commit-parsing-parser-options`. - -The default value for this setting depends on what you specify as -:ref:`commit_parser `. The table below outlines -the expections from ``commit_parser`` value to default options value. - -================== == ================================= -``commit_parser`` Default ``commit_parser_options`` -================== == ================================= -``"angular"`` -> .. code-block:: toml - - [semantic_release.commit_parser_options] - allowed_types = [ - "build", "chore", "ci", "docs", "feat", "fix", - "perf", "style", "refactor", "test" - ] - minor_types = ["feat"] - patch_types = ["fix", "perf"] - -``"emoji"`` -> .. code-block:: toml - - [semantic_release.commit_parser_options] - major_tags = [":boom:"] - minor_tags = [ - ":sparkles:", ":children_crossing:", ":lipstick:", - ":iphone:", ":egg:", ":chart_with_upwards_trend:" - ] - patch_tags = [ - ":ambulance:", ":lock:", ":bug:", ":zap:", ":goal_net:", - ":alien:", ":wheelchair:", ":speech_balloon:", ":mag:", - ":apple:", ":penguin:", ":checkered_flag:", ":robot:", - ":green_apple:" - ] - -``"scipy"`` -> .. code-block:: toml - - [semantic_release.commit_parser_options] - allowed_tags = [ - "API", "DEP", "ENH", "REV", "BUG", "MAINT", "BENCH", - "BLD", "DEV", "DOC", "STY", "TST", "REL", "FEAT", "TEST", - ] - major_tags = ["API",] - minor_tags = ["DEP", "DEV", "ENH", "REV", "FEAT"] - patch_tags = ["BLD", "BUG", "MAINT"] - -``"tag"`` -> .. code-block:: toml - - [semantic_release.commit_parser_options] - minor_tag = ":sparkles:" - patch_tag = ":nut_and_bolt:" - -``"module:class"`` -> ``**module:class.parser_options()`` -================== == ================================= +For more information (to include defaults), see +:ref:`commit_parser-builtin-customization`. **Default:** ``ParserOptions { ... }``, where ``...`` depends on -:ref:`config-commit_parser` as indicated above. +:ref:`commit_parser `. ----