Skip to content

Commit

Permalink
feat: configurable commit validation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
protonjohn committed Jan 2, 2023
1 parent de5c39c commit f624bd3
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 44 deletions.
42 changes: 3 additions & 39 deletions commitizen/commands/check.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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))
3 changes: 2 additions & 1 deletion commitizen/cz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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())
46 changes: 46 additions & 0 deletions commitizen/cz/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from abc import ABCMeta, abstractmethod
from typing import Callable, Dict, List, Optional, Tuple

Expand All @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion commitizen/cz/customize/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .customize import CustomizeCommitsCz # noqa
from .customize import CustomizeCommitsCz, CustomizeCommitValidationCz # noqa
60 changes: 58 additions & 2 deletions commitizen/cz/customize/customize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
)
43 changes: 43 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 41 additions & 1 deletion tests/commands/test_check_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
(
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit f624bd3

Please sign in to comment.