diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index ca739cc91..1765fd5d9 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -94,6 +94,9 @@ class AngularParserOptions(ParserOptions): one of these prefixes, it will not be considered a valid commit message. """ + allowed_scopes: Tuple[str, ...] = () + """If set, only commits with a matching scope will be considered.""" + default_bump_level: LevelBump = LevelBump.NO_RELEASE """The minimum bump level to apply to valid commit message.""" @@ -335,10 +338,28 @@ def is_merge_commit(commit: Commit) -> bool: return len(commit.parents) > 1 def parse_commit(self, commit: Commit) -> ParseResult: - if not (parsed_msg_result := self.parse_message(force_str(commit.message))): + parsed_msg_result = self.parse_message(force_str(commit.message)) + + if not parsed_msg_result: + return _logged_parse_error( + commit, f"Unable to parse commit message: {commit.message!r}" + ) + + # Check if we have defined allowed scopes + has_allowed_scopes = bool(self.options.allowed_scopes) + has_scope = bool(parsed_msg_result.scope) + is_scope_allowed = ( + has_scope and parsed_msg_result.scope in self.options.allowed_scopes + ) + + # If no allowed_scopes are defined, skip filtering + if not has_allowed_scopes: + return ParsedCommit.from_parsed_message_result(commit, parsed_msg_result) + + # If allowed_scopes are defined, enforce filtering + if not has_scope or not is_scope_allowed: return _logged_parse_error( - commit, - f"Unable to parse commit message: {commit.message!r}", + commit, f"Skipping commit due to scope filtering: {commit.message!r}" ) return ParsedCommit.from_parsed_message_result(commit, parsed_msg_result) diff --git a/tests/unit/semantic_release/commit_parser/test_conventional.py b/tests/unit/semantic_release/commit_parser/test_conventional.py index 078e1ecd5..1ab4580b3 100644 --- a/tests/unit/semantic_release/commit_parser/test_conventional.py +++ b/tests/unit/semantic_release/commit_parser/test_conventional.py @@ -1246,3 +1246,34 @@ def test_parser_ignore_merge_commit( assert isinstance(parsed_result, ParseError) assert "Ignoring merge commit" in parsed_result.error + +@pytest.mark.parametrize( + "commit_message, allowed_scopes, expected_result", + [ + ("feat(pkg1): add feature A", ("pkg1",), True), + ("fix(pkg2): fix something", ("pkg1",), False), + ("chore(pkg3): maintenance task", ("pkg3",), True), + ("docs(pkg1): update documentation", ("pkg1", "pkg3"), True), + ("feat(pkg4): add feature B", (), True), # Empty tuple means no scope restriction + ("fix: global fix", ("pkg1",), False), # No scope in message, should not pass + ], +) +def test_parser_scope_filtering(make_commit_obj: MakeCommitObjFn, commit_message, allowed_scopes, expected_result): + """Tests whether ConventionalCommitParser correctly filters commits based on the allowed_scopes option.""" + parser = ConventionalCommitParser( + options=ConventionalCommitParserOptions(allowed_scopes=allowed_scopes) + ) + + parsed_results = parser.parse(make_commit_obj(commit_message)) + + # Ensure that `parsed_results` is a list and extract the first element (if any) + assert isinstance(parsed_results, list), f"Expected list but got {type(parsed_results)}" + assert len(parsed_results) > 0, "Expected at least one parsed result" + + result = parsed_results[0] # Extract the first parsed commit or error + + if expected_result: + assert isinstance(result, ParsedCommit), f"Expected ParsedCommit but got {result}" + else: + assert isinstance(result, ParseError), f"Expected ParseError but got {result}" + assert "Skipping commit due to scope filtering" in result.error