"""Conventional-commit + ISSUES CLOSED footer linter. Extracted from :mod:`_review_validate_helpers` in Phase 0 so the implementer pipeline can reuse the same lint without depending on reviewer-specific helpers. The implementer's own self-validation CLI (:mod:`implementer_validate`) and the reviewer's CLI (:mod:`review_validate`) both call :func:`lint_commit_message` — keeping the lint here gives them a single source of truth and matches the project's CONTRIBUTING.md / worker-prompt contract. What's here: - :data:`CONVENTIONAL_TYPES` -- whitelist of conventional-commit type prefixes (``feat`` / ``fix`` / ``refactor`` / ...). - :func:`lint_commit_message` -- subject-prefix + ISSUES CLOSED footer + bot-author exemption check. Returns a structured dict matching the CLI output contract; never raises. - :func:`_bot_committer_email` -- resolves the bot identity from ``REVIEW_BOT_COMMITTER_EMAIL`` (default ``forgejo@cleverthis.com``). Bot-authored merge commits bypass every check. The lint is INTENTIONALLY identical between dispatchers: a draft that passes the reviewer's lint must also pass the implementer's lint by construction, so a worker can self-validate against either CLI and trust the verdict. """ from __future__ import annotations import os import re from typing import Any CONVENTIONAL_TYPES: tuple[str, ...] = ( "feat", "fix", "refactor", "perf", "docs", "test", "build", "ci", "chore", "style", "revert", ) _CONVENTIONAL_SUBJECT_RE = re.compile( r"^(?P" + "|".join(CONVENTIONAL_TYPES) + r")" r"(?:\([^)]+\))?" # optional scope r"(?P!?)" # optional breaking-change marker r": (?P.+)$" ) _ISSUES_CLOSED_RE = re.compile( r"(?im)^ISSUES CLOSED:\s*#(?P\d+)(?:\s*,\s*#\d+)*\s*$" ) def _bot_committer_email() -> str: """Resolve the bot committer email used for the lint-commit exemption. Defaults to ``forgejo@cleverthis.com``, overridable via ``REVIEW_BOT_COMMITTER_EMAIL`` so forks / test deployments don't have to monkey-patch a private constant. Always lowercased so the callers' comparison is case-insensitive.""" return os.environ.get( "REVIEW_BOT_COMMITTER_EMAIL", "forgejo@cleverthis.com" ).lower() # Module-level constant for back-compat with older tests / callers # that patch this directly. New code should call # :func:`_bot_committer_email` at use site so a per-test # ``monkeypatch.setenv`` flows through without re-importing. BOT_COMMITTER_EMAIL = _bot_committer_email() def lint_commit_message( message: str, committer_email: str, *, is_head: bool ) -> dict[str, Any]: """Lint one commit's message + committer. Returns the same shape the CLIs ultimately serialise to stdout:: {"ok": bool, "exempt_bot": bool, "violations": [{"rule": ..., "details": ...}, ...]} Bot-authored merge commits (committer email matches :func:`_bot_committer_email` verbatim) bypass every check — they are mechanical merge artifacts (umbrella PR titles, train-merge sub-merges) and were never produced by an implementer. Per CONTRIBUTING.md / the worker prompt's Review Checklist category 10 exemption. The bot identity is overridable via ``REVIEW_BOT_COMMITTER_EMAIL`` for forks / test deployments. The ``is_head`` flag activates the ``ISSUES CLOSED: #N`` footer check; only the head commit is required to carry it (the merge driver checks the merge commit, the linter must run on HEAD here because the dispatcher does not produce a merge commit until the merge supervisor runs much later). """ # Resolve at use site so a test's ``monkeypatch.setenv`` (set # AFTER module import) is honoured without requiring a fresh # re-import. Both sides are lowercased to make the comparison # case-insensitive even if a future operator overrides the env # var to mixed case. exempt_bot = committer_email.lower() == _bot_committer_email() violations: list[dict[str, str]] = [] if not exempt_bot: subject = (message.splitlines() or [""])[0].strip() if not _CONVENTIONAL_SUBJECT_RE.match(subject): violations.append( { "rule": "subject-prefix", "details": ( f"subject {subject!r} does not match conventional-commit " f"format [(scope)][!]: ; allowed " f"types: {', '.join(CONVENTIONAL_TYPES)}" ), } ) if is_head and not _ISSUES_CLOSED_RE.search(message): violations.append( { "rule": "issues-closed-footer", "details": ( "head commit body is missing the required " "'ISSUES CLOSED: #N' footer (case-insensitive, " "must be on its own line)" ), } ) return { "ok": not violations, "exempt_bot": exempt_bot, "violations": violations, } __all__ = ( "BOT_COMMITTER_EMAIL", "CONVENTIONAL_TYPES", "_bot_committer_email", "lint_commit_message", )