diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index e6ebc928e..bc9c72301 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -78,25 +78,30 @@ def __call__(self) -> None: """Validate if commit messages follows the conventional pattern. Raises: - InvalidCommitMessageError: if the commit provided not follows the conventional pattern + InvalidCommitMessageError: if the commit provided does not follow the conventional pattern """ commits = self._get_commits() if not commits: raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'") pattern = re.compile(self.cz.schema_pattern()) - invalid_msgs_content = "\n".join( - f'commit "{commit.rev}": "{commit.message}"' + invalid_commits = [ + (commit, check.errors) for commit in commits - if not self._validate_commit_message(commit.message, pattern) - ) - if invalid_msgs_content: - # TODO: capitalize the first letter of the error message for consistency in v5 + if not ( + check := self.cz.validate_commit_message( + commit_msg=commit.message, + pattern=pattern, + allow_abort=self.allow_abort, + allowed_prefixes=self.allowed_prefixes, + max_msg_length=self.max_msg_length, + ) + ).is_valid + ] + + if invalid_commits: raise InvalidCommitMessageError( - "commit validation: failed!\n" - "please enter a commit message in the commitizen format.\n" - f"{invalid_msgs_content}\n" - f"pattern: {pattern.pattern}" + self.cz.format_exception_message(invalid_commits) ) out.success("Commit validation: successful!") diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index cdc147669..40146e310 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,8 +1,9 @@ from __future__ import annotations +import re from abc import ABCMeta, abstractmethod from collections.abc import Iterable, Mapping -from typing import Any, Callable, Protocol +from typing import Any, Callable, NamedTuple, Protocol from jinja2 import BaseLoader, PackageLoader from prompt_toolkit.styles import Style, merge_styles @@ -24,6 +25,11 @@ def __call__( ) -> dict[str, Any]: ... +class ValidationResult(NamedTuple): + is_valid: bool + errors: list + + class BaseCommitizen(metaclass=ABCMeta): bump_pattern: str | None = None bump_map: dict[str, str] | None = None @@ -41,7 +47,7 @@ class BaseCommitizen(metaclass=ABCMeta): ("disabled", "fg:#858585 italic"), ] - # The whole subject will be parsed as message by default + # The whole subject will be parsed as a message by default # This allows supporting changelog for any rule system. # It can be modified per rule commit_parser: str | None = r"(?P.*)" @@ -96,6 +102,54 @@ def schema_pattern(self) -> str: """Regex matching the schema used for message validation.""" raise NotImplementedError("Not Implemented yet") + def validate_commit_message( + self, + *, + commit_msg: str, + pattern: re.Pattern[str] | None, + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int, + ) -> ValidationResult: + """Validate commit message against the pattern.""" + if not commit_msg: + return ValidationResult(allow_abort, []) + + if pattern is None: + return ValidationResult(True, []) + + if any(map(commit_msg.startswith, allowed_prefixes)): + return ValidationResult(True, []) + + if max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + return ValidationResult( + False, + [f"message is too long: {msg_len} > {max_msg_length}"], + ) + + return ValidationResult( + bool(pattern.match(commit_msg)), + [f"pattern: {pattern.pattern}"], + ) + + def format_exception_message( + self, invalid_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + f'commit "{commit.rev}": "{commit.message}\n"' + "\n".join(errors) + for commit, errors in invalid_commits + ] + ) + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}" + ) + def info(self) -> str: """Information about the standardized commit message.""" raise NotImplementedError("Not Implemented yet") diff --git a/docs/customization.md b/docs/customization.md index df7717107..198fca53e 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -312,6 +312,73 @@ cz -n cz_strange bump [convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py +### Custom commit validation and error message + +The commit message validation can be customized by overriding the `validate_commit_message` and `format_error_message` +methods from `BaseCommitizen`. This allows for a more detailed feedback to the user where the error originates from. + +```python +import re + +from commitizen.cz.base import BaseCommitizen +from commitizen import git + + +class CustomValidationCz(BaseCommitizen): + def validate_commit_message( + self, + *, + commit_msg: str, + pattern: str | None, + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int, + ) -> tuple[bool, list]: + """Validate commit message against the pattern.""" + if not commit_msg: + return allow_abort, [] if allow_abort else [f"commit message is empty"] + + if pattern is None: + return True, [] + + if any(map(commit_msg.startswith, allowed_prefixes)): + return True, [] + if max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + return False, [ + f"commit message is too long. Max length is {max_msg_length}" + ] + pattern_match = re.match(pattern, commit_msg) + if pattern_match: + return True, [] + else: + # Perform additional validation of the commit message format + # and add custom error messages as needed + return False, ["commit message does not match the pattern"] + + def format_exception_message( + self, ill_formated_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + ( + f'commit "{commit.rev}": "{commit.message}"' + f"errors:\n" + "\n".join((f"- {error}" for error in errors)) + ) + for commit, errors in ill_formated_commits + ] + ) + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}\n" + f"pattern: {self.schema_pattern()}" + ) +``` + ### Custom changelog generator The changelog generator should just work in a very basic manner without touching anything. diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index d95a173d8..82d5335b8 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -452,3 +452,44 @@ def test_check_command_with_message_length_limit_exceeded(config, mocker: MockFi with pytest.raises(InvalidCommitMessageError): check_cmd() error_mock.assert_called_once() + + +@pytest.mark.usefixtures("use_cz_custom_validator") +def test_check_command_with_custom_validator_succeed(mocker: MockFixture, capsys): + testargs = [ + "cz", + "--name", + "cz_custom_validator", + "check", + "--commit-msg-file", + "some_file", + ] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="ABC-123: add commitizen pre-commit hook"), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +@pytest.mark.usefixtures("use_cz_custom_validator") +def test_check_command_with_custom_validator_failed(mocker: MockFixture): + testargs = [ + "cz", + "--name", + "cz_custom_validator", + "check", + "--commit-msg-file", + "some_file", + ] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="ABC-123 add commitizen pre-commit hook"), + ) + with pytest.raises(InvalidCommitMessageError) as excinfo: + cli.main() + assert "commit validation: failed!" in str(excinfo.value) + assert "pattern: " in str(excinfo.value) diff --git a/tests/conftest.py b/tests/conftest.py index 324ef9beb..0dccff763 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,20 +3,21 @@ import os import re import tempfile -from collections.abc import Iterator, Mapping +from collections.abc import Iterable, Iterator, Mapping from pathlib import Path +from typing import Any import pytest from pytest_mock import MockerFixture -from commitizen import cmd, defaults +from commitizen import cmd, defaults, git from commitizen.changelog_formats import ( ChangelogFormat, get_changelog_format, ) from commitizen.config import BaseConfig from commitizen.cz import registry -from commitizen.cz.base import BaseCommitizen +from commitizen.cz.base import BaseCommitizen, ValidationResult from commitizen.question import CzQuestion from tests.utils import create_file_and_commit @@ -223,7 +224,7 @@ def use_cz_semver(mocker): class MockPlugin(BaseCommitizen): - def questions(self) -> list[CzQuestion]: + def questions(self) -> Iterable[CzQuestion]: return [] def message(self, answers: Mapping) -> str: @@ -237,6 +238,82 @@ def mock_plugin(mocker: MockerFixture, config: BaseConfig) -> BaseCommitizen: return mock +class ValidationCz(BaseCommitizen): + def questions(self) -> Iterable[CzQuestion]: + return [ + {"type": "input", "name": "commit", "message": "Initial commit:\n"}, + {"type": "input", "name": "issue_nb", "message": "ABC-123"}, + ] + + def message(self, answers: Mapping[str, Any]) -> str: + return f"{answers['issue_nb']}: {answers['commit']}" + + def schema(self) -> str: + return ": " + + def schema_pattern(self) -> str: + return r"^(?P[A-Z]{3}-\d+): (?P.*)$" + + def validate_commit_message( + self, + *, + commit_msg: str, + pattern: re.Pattern[str] | None, + allow_abort: bool, + allowed_prefixes: list[str], + max_msg_length: int, + ) -> ValidationResult: + """Validate commit message against the pattern.""" + if not commit_msg: + return ValidationResult( + allow_abort, [] if allow_abort else ["commit message is empty"] + ) + + if pattern is None: + return ValidationResult(True, []) + + if any(map(commit_msg.startswith, allowed_prefixes)): + return ValidationResult(True, []) + + if max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > max_msg_length: + return ValidationResult( + False, + [f"message is too long: {msg_len} > {max_msg_length}"], + ) + + return ValidationResult( + bool(pattern.match(commit_msg)), [f"pattern: {pattern.pattern}"] + ) + + def format_exception_message( + self, invalid_commits: list[tuple[git.GitCommit, list]] + ) -> str: + """Format commit errors.""" + displayed_msgs_content = "\n".join( + [ + ( + f'commit "{commit.rev}": "{commit.message}"\nerrors:\n\n'.join( + f"- {error}" for error in errors + ) + ) + for (commit, errors) in invalid_commits + ] + ) + return ( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}" + ) + + +@pytest.fixture +def use_cz_custom_validator(mocker): + new_cz = {**registry, "cz_custom_validator": ValidationCz} + mocker.patch.dict("commitizen.cz.registry", new_cz) + + SUPPORTED_FORMATS = ("markdown", "textile", "asciidoc", "restructuredtext") diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 0ee5a23fb..67aa53d26 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -2,7 +2,7 @@ import pytest -from commitizen.cz.base import BaseCommitizen +from commitizen.cz.base import BaseCommitizen, ValidationResult class DummyCz(BaseCommitizen): @@ -12,6 +12,9 @@ def questions(self): def message(self, answers: Mapping): return answers["commit"] + def schema_pattern(self) -> str: + return ".*" + def test_base_raises_error(config): with pytest.raises(TypeError): @@ -40,6 +43,17 @@ def test_schema(config): cz.schema() +def test_validate_commit_message(config): + cz = DummyCz(config) + assert cz.validate_commit_message( + commit_msg="test", + pattern=None, + allow_abort=False, + allowed_prefixes=[], + max_msg_length=0, + ) == ValidationResult(True, []) + + def test_info(config): cz = DummyCz(config) with pytest.raises(NotImplementedError):