diff --git a/.gitignore b/.gitignore index 6e9ea54f..3dfd5ade 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ commit_check.egg-info __pycache__ .mypy_cache .vscode +*.swp venv .venv UNKNOWN.egg-info diff --git a/README.md b/README.md index 75a90176..adbfece3 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,11 @@ repos: args: - --subject-imperative=false - --subject-max-length=100 + - id: check-author-email + args: + - --no-banner + - --author-email + - --author-email-pattern=^.+@example\.com$ ``` See the [Configuration documentation](https://commit-check.github.io/commit-check/configuration.html) for all available options. diff --git a/commit_check/config_merger.py b/commit_check/config_merger.py index 149e9da0..c9c88e56 100644 --- a/commit_check/config_merger.py +++ b/commit_check/config_merger.py @@ -78,6 +78,8 @@ def get_default_config() -> dict[str, Any]: "require_signed_off_by": DEFAULT_BOOLEAN_RULES["require_signed_off_by"], "ignore_authors": [], "ai_attribution": DEFAULT_AI_ATTRIBUTION, + "author_email_pattern": "^.+@.+$", + "author_name_pattern": "", }, "branch": { "conventional_branch": True, @@ -123,6 +125,8 @@ class ConfigMerger: "CCHK_REQUIRE_SIGNED_OFF_BY": ("commit", "require_signed_off_by", parse_bool), "CCHK_IGNORE_AUTHORS": ("commit", "ignore_authors", parse_list), "CCHK_AI_ATTRIBUTION": ("commit", "ai_attribution", str), + "CCHK_AUTHOR_EMAIL_PATTERN": ("commit", "author_email_pattern", str), + "CCHK_AUTHOR_NAME_PATTERN": ("commit", "author_name_pattern", str), # Branch section "CCHK_CONVENTIONAL_BRANCH": ("branch", "conventional_branch", parse_bool), "CCHK_ALLOW_BRANCH_TYPES": ("branch", "allow_branch_types", parse_list), @@ -151,6 +155,8 @@ class ConfigMerger: "require_signed_off_by": ("commit", "require_signed_off_by"), "ignore_authors": ("commit", "ignore_authors"), "ai_attribution": ("commit", "ai_attribution"), + "author_email_pattern": ("commit", "author_email_pattern"), + "author_name_pattern": ("commit", "author_name_pattern"), # Branch section "conventional_branch": ("branch", "conventional_branch"), "allow_branch_types": ("branch", "allow_branch_types"), diff --git a/commit_check/main.py b/commit_check/main.py index 07fcb595..af7d2cd6 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -311,6 +311,22 @@ def _get_parser() -> argparse.ArgumentParser: "'forbid' rejects commits with known AI tool signatures.", ) + commit_group.add_argument( + "--author-email-pattern", + type=str, + default=None, + metavar="REGEX", + help="regex to check author email (requires --author-email)", + ) + + commit_group.add_argument( + "--author-name-pattern", + type=str, + default=None, + metavar="REGEX", + help="regex to check author name (requires --author-name)", + ) + # Branch configuration options branch_group = parser.add_argument_group( "branch options", "Configuration options for --branch validation" diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index cc768a00..6e7f7b13 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -141,6 +141,12 @@ def _build_single_rule( return self._build_author_list_rule(catalog_entry, "ignore_authors") elif check == "ai_attribution": return self._build_ai_attribution_rule(catalog_entry) + elif check == "author_email": + return self._build_author_pattern_rule( + catalog_entry, "author_email_pattern" + ) + elif check == "author_name": + return self._build_author_pattern_rule(catalog_entry, "author_name_pattern") elif check == "merge_base": return self._build_merge_base_rule(catalog_entry) else: @@ -236,6 +242,21 @@ def _build_author_list_rule( return ValidationRule(check=catalog_entry.check, ignored=author_list) return None + def _build_author_pattern_rule( + self, catalog_entry: RuleCatalogEntry, config_key: str + ) -> ValidationRule | None: + """Build author name or email validation rule.""" + regex = catalog_entry.regex + if self.commit_config.get(config_key, ""): + regex = self.commit_config.get(config_key, "").strip() + + return ValidationRule( + check=catalog_entry.check, + regex=regex, + error=catalog_entry.error, + suggest=catalog_entry.suggest, + ) + def _build_merge_base_rule( self, catalog_entry: RuleCatalogEntry ) -> ValidationRule | None: diff --git a/docs/configuration.rst b/docs/configuration.rst index 1a4416d2..6b42c9b9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -274,6 +274,12 @@ Configuration can also be set via environment variables with the ``CCHK_`` prefi * - ``ignore_authors = ["bot"]`` - ``CCHK_IGNORE_AUTHORS=bot,user`` - ``--ignore-authors=bot,user`` + * - ``author_email_pattern=^.+@example\.com$`` + - ``CCHK_AUTHOR_EMAIL_PATTERN=^.+@example\.com$`` + - ``--author-email-pattern=^.+@example\.com$`` + * - ``author_name_pattern=^.+ .+$`` + - ``CCHK_AUTHOR_NAME_PATTERN=^.+ .+$`` + - ``--author-name-pattern=^.+ .+$`` * - ``conventional_branch = true`` - ``CCHK_CONVENTIONAL_BRANCH=true`` - ``--conventional-branch=true`` @@ -397,6 +403,18 @@ Options Table Description - list[str] - [] (none ignored) - List of commit authors **or co-authors** (``Co-authored-by:`` lines) to bypass all commit checks. Useful for bots (e.g., ``"dependabot[bot]"``, ``"coderabbitai[bot]"``). + * - commit + - author_email_pattern + - str + - ^.+@.+$ + - Custom regex for the author email check. When empty, the built-in default pattern is used. + This option only takes effect when the author_email check is enabled (``-e`` / ``--author-email``). + * - commit + - author_name_pattern + - str + - "" (built-in default) + - Custom regex for the author name check. When empty, the built-in default pattern is used (it is not disabled). + This option only takes effect when the author_name check is enabled (``-n`` / ``--author-name``). * - commit - require_signed_off_by - bool diff --git a/tests/config_merger_test.py b/tests/config_merger_test.py index fa41060f..eddf0e9e 100644 --- a/tests/config_merger_test.py +++ b/tests/config_merger_test.py @@ -175,6 +175,13 @@ def test_parse_branch_env_vars(self, monkeypatch): assert config["branch"]["conventional_branch"] is False assert config["branch"]["allow_branch_types"] == ["feature", "bugfix"] + def test_parse_author_pattern_env_vars(self, monkeypatch): + monkeypatch.setenv("CCHK_AUTHOR_NAME_PATTERN", r"^[A-Z][a-z]+ [A-Z][a-z]+$") + monkeypatch.setenv("CCHK_AUTHOR_EMAIL_PATTERN", r"^.+@company\.com$") + config = ConfigMerger.parse_env_vars() + assert config["commit"]["author_name_pattern"] == r"^[A-Z][a-z]+ [A-Z][a-z]+$" + assert config["commit"]["author_email_pattern"] == r"^.+@company\.com$" + def test_invalid_env_var_is_skipped(self, monkeypatch, capsys): monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "invalid") config = ConfigMerger.parse_env_vars() diff --git a/tests/engine_test.py b/tests/engine_test.py index ec4c4e64..9d97ce81 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -556,6 +556,57 @@ def test_get_author_value_with_email_format(self): assert author_value == "test@example.com" +class TestAuthorPatternConfig: + """Tests for configurable author_name_pattern / author_email_pattern. + + Rules are built through RuleBuilder so the actual config resolution + (custom pattern override + fallback to the built-in catalog regex) is + exercised, not just an inline regex. + """ + + @staticmethod + def _author_rule(commit_config, check): + builder = RuleBuilder({"commit": commit_config}) + rules = builder.build_all_rules() + return next(r for r in rules if r.check == check) + + @staticmethod + def _validate(rule, author_value): + validator = AuthorValidator(rule) + with patch("commit_check.util._print_failure"): + return validator.validate(ValidationContext(stdin_text=author_value)) + + @pytest.mark.benchmark + def test_custom_name_pattern_pass_and_fail(self): + """A custom author_name_pattern accepts matches and rejects non-matches.""" + rule = self._author_rule( + {"author_name_pattern": r"^[A-Z][a-z]+ [A-Z][a-z]+$"}, "author_name" + ) + assert self._validate(rule, "Jane Doe") == ValidationResult.PASS + assert self._validate(rule, "jane") == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_custom_email_pattern_enforces_domain(self): + """A custom author_email_pattern can enforce a company domain.""" + rule = self._author_rule( + {"author_email_pattern": r"^.+@company\.com$"}, "author_email" + ) + assert self._validate(rule, "bob@company.com") == ValidationResult.PASS + assert self._validate(rule, "bob@gmail.com") == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_default_name_pattern_uses_builtin_regex(self): + """With no custom pattern, the built-in catalog regex still applies. + + Regression guard: an empty/omitted author_name_pattern must not disable + the check — it should fall back to the shipped default so an invalid + name is still rejected. + """ + rule = self._author_rule({}, "author_name") + assert self._validate(rule, "Jane Doe") == ValidationResult.PASS + assert self._validate(rule, "12345 !!!") == ValidationResult.FAIL + + class TestCommitTypeValidator: @pytest.mark.benchmark def test_commit_type_validator_merge_commits(self):