From f624bd321d84d7c4e5123e4abcfe1c9833f5f9e0 Mon Sep 17 00:00:00 2001 From: John Biggs Date: Fri, 30 Dec 2022 14:03:58 +0100 Subject: [PATCH] feat: configurable commit validation Allow clients overriding the BaseCommitizen object to have more custom commit message validation beyond just matching a regex schema. This lets clients have custom error messages and more intelligent pattern matching. --- commitizen/commands/check.py | 42 ++----------------- commitizen/cz/__init__.py | 3 +- commitizen/cz/base.py | 46 +++++++++++++++++++++ commitizen/cz/customize/__init__.py | 2 +- commitizen/cz/customize/customize.py | 60 +++++++++++++++++++++++++++- docs/customization.md | 43 ++++++++++++++++++++ tests/commands/test_check_command.py | 42 ++++++++++++++++++- 7 files changed, 194 insertions(+), 44 deletions(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index e42dbf7f49..4b7bc1c081 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -1,15 +1,10 @@ import os -import re import sys from typing import Any, Dict, Optional from commitizen import factory, git, out from commitizen.config import BaseConfig -from commitizen.exceptions import ( - InvalidCommandArgumentError, - InvalidCommitMessageError, - NoCommitsFoundError, -) +from commitizen.exceptions import InvalidCommandArgumentError, NoCommitsFoundError class Check: @@ -54,31 +49,13 @@ def __call__(self): """Validate if commit messages follows the conventional pattern. Raises: - InvalidCommitMessageError: if the commit provided not follows the conventional pattern + InvalidCommitMessageError: if the provided commit 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 = self.cz.schema_pattern() - ill_formated_commits = [ - commit - for commit in commits - if not self.validate_commit_message(commit.message, pattern) - ] - displayed_msgs_content = "\n".join( - [ - f'commit "{commit.rev}": "{commit.message}"' - for commit in ill_formated_commits - ] - ) - if displayed_msgs_content: - raise InvalidCommitMessageError( - "commit validation: failed!\n" - "please enter a commit message in the commitizen format.\n" - f"{displayed_msgs_content}\n" - f"pattern: {pattern}" - ) + self.cz.validate_commits(commits, self.allow_abort) out.success("Commit validation: successful!") def _get_commits(self): @@ -128,16 +105,3 @@ def _filter_comments(msg: str) -> str: if not line.startswith("#"): lines.append(line) return "\n".join(lines) - - def validate_commit_message(self, commit_msg: str, pattern: str) -> bool: - if not commit_msg: - return self.allow_abort - if ( - commit_msg.startswith("Merge") - or commit_msg.startswith("Revert") - or commit_msg.startswith("Pull request") - or commit_msg.startswith("fixup!") - or commit_msg.startswith("squash!") - ): - return True - return bool(re.match(pattern, commit_msg)) diff --git a/commitizen/cz/__init__.py b/commitizen/cz/__init__.py index e14cb9f7c9..8f0350fa31 100644 --- a/commitizen/cz/__init__.py +++ b/commitizen/cz/__init__.py @@ -5,7 +5,7 @@ from commitizen.cz.base import BaseCommitizen from commitizen.cz.conventional_commits import ConventionalCommitsCz -from commitizen.cz.customize import CustomizeCommitsCz +from commitizen.cz.customize import CustomizeCommitsCz, CustomizeCommitValidationCz from commitizen.cz.jira import JiraSmartCz @@ -34,6 +34,7 @@ def discover_plugins(path: Iterable[str] = None) -> Dict[str, Type[BaseCommitize "cz_conventional_commits": ConventionalCommitsCz, "cz_jira": JiraSmartCz, "cz_customize": CustomizeCommitsCz, + "cz_custom_validation": CustomizeCommitValidationCz, } registry.update(discover_plugins()) diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index a5abe35f16..2c8da1c3db 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -1,3 +1,4 @@ +import re from abc import ABCMeta, abstractmethod from typing import Callable, Dict, List, Optional, Tuple @@ -6,6 +7,7 @@ from commitizen import git from commitizen.config.base_config import BaseConfig from commitizen.defaults import Questions +from commitizen.exceptions import InvalidCommitMessageError, InvalidConfigurationError class BaseCommitizen(metaclass=ABCMeta): @@ -74,6 +76,50 @@ def schema_pattern(self) -> Optional[str]: """Regex matching the schema used for message validation.""" raise NotImplementedError("Not Implemented yet") + def validate_commit_message( + self, commit: git.GitCommit, pattern: str, allow_abort: bool + ) -> bool: + if not commit.message: + return allow_abort + if ( + commit.message.startswith("Merge") + or commit.message.startswith("Revert") + or commit.message.startswith("Pull request") + or commit.message.startswith("fixup!") + or commit.message.startswith("squash!") + ): + return True + return bool(re.match(pattern, commit.message)) + + def validate_commits(self, commits: List[git.GitCommit], allow_abort: bool): + """ + Validate a commit. Invokes schema_pattern by default. + Raises: + InvalidCommitMessageError: if the provided commit does not follow the conventional pattern + """ + + pattern = self.schema_pattern() + if pattern is None: + raise InvalidConfigurationError( + "self.schema_pattern() should not return None" + ) + + displayed_msgs_content = "\n".join( + [ + f'commit "{commit.rev}": "{commit.message}"' + for commit in commits + if not self.validate_commit_message(commit, pattern, allow_abort) + ] + ) + + if displayed_msgs_content: + raise InvalidCommitMessageError( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}\n" + f"pattern: {pattern}" + ) + def info(self) -> Optional[str]: """Information about the standardized commit message.""" raise NotImplementedError("Not Implemented yet") diff --git a/commitizen/cz/customize/__init__.py b/commitizen/cz/customize/__init__.py index c5af001a79..2052f5c0fa 100644 --- a/commitizen/cz/customize/__init__.py +++ b/commitizen/cz/customize/__init__.py @@ -1 +1 @@ -from .customize import CustomizeCommitsCz # noqa +from .customize import CustomizeCommitsCz, CustomizeCommitValidationCz # noqa diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 994c21c714..99cd973cd4 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -3,15 +3,19 @@ except ImportError: from string import Template # type: ignore +import re from typing import Optional from commitizen import defaults from commitizen.config import BaseConfig from commitizen.cz.base import BaseCommitizen from commitizen.defaults import Questions -from commitizen.exceptions import MissingCzCustomizeConfigError +from commitizen.exceptions import ( + InvalidCommitMessageError, + MissingCzCustomizeConfigError, +) -__all__ = ["CustomizeCommitsCz"] +__all__ = ["CustomizeCommitsCz", "CustomizeCommitValidationCz"] class CustomizeCommitsCz(BaseCommitizen): @@ -79,3 +83,55 @@ def info(self) -> Optional[str]: elif info: return info return None + + +class CustomizeCommitValidationCz(CustomizeCommitsCz): + def validate_commit(self, pattern, commit, allow_abort): + """ + Validates that a commit message doesn't contain certain tokens. + """ + if not commit.message: + if not allow_abort: + raise InvalidCommitMessageError("Commit message shouldn't be empty.") + return True + + invalid_tokens = ["Merge", "Revert", "Pull request", "fixup!", "squash!"] + for token in invalid_tokens: + if commit.message.startswith(token): + raise InvalidCommitMessageError( + f"Commits may not start with the token {token}." + ) + + return re.match(pattern, commit.message) + + def validate_commits(self, commits, allow_abort): + """ + Validates a list of commits against the configured commit validation schema. + See the schema() and example() functions for examples. + """ + + pattern = self.schema_pattern() + + displayed_msgs_content = [] + for commit in commits: + message = "" + valid = False + try: + valid = self.validate_commit(pattern, commit, allow_abort) + except InvalidCommitMessageError as e: + message = e.message + + if not valid: + displayed_msgs_content.append( + f"commit {commit.rev}\n" + f"Author: {commit.author} <{commit.author_email}>\n\n" + f"{commit.message}\n\n" + f"commit validation: failed! {message}\n" + ) + + if displayed_msgs_content: + displayed_msgs = "\n".join(displayed_msgs_content) + raise InvalidCommitMessageError( + f"{displayed_msgs}\n" + "Please enter a commit message in the commitizen format.\n" + ) diff --git a/docs/customization.md b/docs/customization.md index 053dbc1230..f6e60af5be 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -317,6 +317,49 @@ cz -n cz_strange bump [convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py +### Custom Commit Validation + +The default implementation of `validate_commits` will call `validate_commit` for each commit passed as input to the `check` command, and evaluate +it against the `schema_pattern` regular expression. You can override those 3 functions on `BaseCommitizen` for different levels of flexibility. + +If you raise an `InvalidCommitMessageError` from `validate_commit`, the default implementation of `validate_commits` will include the message from +that error in its final output. + +```python +import re +from commitizen.cz.base import BaseCommitizen +from commitizen.git import GitCommit + +class StrangeCommitizen(BaseCommitizen): + def schema_pattern(self) -> Optional[str]: + # collect the different prefix options into a regex + choices = ["feat", "fix", "chore", "bump"] + prefixes_regex = r"|".join(choices) + + return ( + f"({prefixes_regex})" # different prefixes + "(\(\S+\))?!?:(\s.*)" # scope & subject body + ) + + def validate_commit(self, pattern: str, commit: GitCommit, allow_abort: bool) -> bool: + return allow_abort if not re.match(pattern, commit.message) else True + + def validate_commits(self, commits: List[GitCommit], allow_abort: bool): + pattern = self.schema_pattern() + invalid_commits = "\n".join([ + ( + f'"{commit.rev}": "{commit.message}"\n' + "commit validation failed!" + ) + for commit in commits + if not self.validate_commit(pattern, commit, allow_abort) + ]) + + if invalid_commits: + raise InvalidCommitMessageError(invalid_commits) + +``` + ### 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 9e9182e6f2..cc0b77b605 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -128,6 +128,46 @@ def test_check_conventional_commit_succeeds(mocker: MockFixture, capsys): assert "Commit validation: successful!" in out +def test_check_custom_validation_succeeds(mocker, capsys, config_customize): + testargs = [ + "cz", + "-n", + "cz_custom_validation", + "check", + "--commit-msg-file", + "some_file", + ] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="fix(scope): some commit message"), + ) + mocker.patch("commitizen.config.read_cfg", return_value=config_customize) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_custom_validation_fails(mocker, config_customize): + testargs = [ + "cz", + "-n", + "cz_custom_validation", + "check", + "--commit-msg-file", + "some_file", + ] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="fixup! fix(scope): some commit message"), + ) + mocker.patch("commitizen.config.read_cfg", return_value=config_customize) + with pytest.raises(InvalidCommitMessageError) as excinfo: + cli.main() + assert "commit validation: failed!" in str(excinfo.value) + + @pytest.mark.parametrize( "commit_msg", ( @@ -332,7 +372,7 @@ def test_check_command_with_pipe_message_and_failed(mocker: MockFixture): assert "commit validation: failed!" in str(excinfo.value) -def test_check_command_with_comment_in_messege_file(mocker: MockFixture, capsys): +def test_check_command_with_comment_in_message_file(mocker: MockFixture, capsys): testargs = ["cz", "check", "--commit-msg-file", "some_file"] mocker.patch.object(sys, "argv", testargs) mocker.patch(