"""Conventional commit validation and utilities.""" import re from dataclasses import dataclass from typing import Optional VALID_TYPES = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"] CONVENTIONAL_PATTERN = re.compile( r"^(?Pfeat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)" r"(?:\((?P[^)]+)\))?: (?P.+)$" ) @dataclass class ParsedCommit: """Parsed conventional commit message.""" type: str scope: Optional[str] description: str raw: str @property def formatted(self) -> str: if self.scope: return f"{self.type}({self.scope}): {self.description}" return f"{self.type}: {self.description}" class ConventionalCommitParser: """Parser for conventional commit messages.""" @staticmethod def parse(message: str) -> Optional[ParsedCommit]: message = message.strip() match = CONVENTIONAL_PATTERN.match(message) if match: return ParsedCommit( type=match.group("type"), scope=match.group("scope"), description=match.group("description"), raw=message) return None @staticmethod def is_valid(message: str) -> bool: return ConventionalCommitParser.parse(message) is not None @staticmethod def validate(message: str) -> list[str]: errors = [] message = message.strip() if not message: errors.append("Commit message cannot be empty") return errors if not CONVENTIONAL_PATTERN.match(message): errors.append("Message does not follow conventional commit format. Expected: type(scope): description") return errors parsed = ConventionalCommitParser.parse(message) if parsed: if parsed.type not in VALID_TYPES: errors.append(f"Invalid type '{parsed.type}'. Valid types: {', '.join(VALID_TYPES)}") if parsed.scope and len(parsed.scope) > 20: errors.append("Scope is too long (max 20 characters)") return errors class ConventionalCommitFixer: """Auto-fixer for conventional commit format issues.""" @staticmethod def fix(message: str, diff: str) -> str: message = message.strip() type_hint = ConventionalCommitFixer._detect_type(diff) if not type_hint: type_hint = "chore" description = ConventionalCommitFixer._extract_description(message, diff) if description: return f"{type_hint}: {description}" return message @staticmethod def _detect_type(diff: str) -> Optional[str]: diff_lower = diff.lower() if any(kw in diff_lower for kw in ["bug", "fix", "error", "issue", "problem"]): return "fix" if any(kw in diff_lower for kw in ["feature", "add", "implement", "new"]): return "feat" if any(kw in diff_lower for kw in ["doc", "readme", "comment"]): return "docs" if any(kw in diff_lower for kw in ["test", "spec"]): return "test" if any(kw in diff_lower for kw in ["refactor", "restructure", "reorganize"]): return "refactor" if any(kw in diff_lower for kw in ["style", "format", "lint"]): return "style" if any(kw in diff_lower for kw in ["perf", "optimize", "speed", "performance"]): return "perf" if any(kw in diff_lower for kw in ["build", "ci", "docker", "pipeline"]): return "build" return None @staticmethod def _extract_description(message: str, diff: str) -> str: if message and len(message) > 3: cleaned = message.strip() if ":" in cleaned: cleaned = cleaned.split(":", 1)[1].strip() if len(cleaned) > 3: return cleaned[:72].rsplit(" ", 1)[0] if " " in cleaned else cleaned files = ConventionalCommitFixer._get_changed_files(diff) if files: action = ConventionalCommitFixer._get_action(diff) return f"{action} {files[0]}" return "" @staticmethod def _get_changed_files(diff: str) -> list[str]: files = [] for line in diff.split("\n"): if line.startswith("+++ b/") or line.startswith("--- a/"): path = line[6:] if path and path != "/dev/null": filename = path.split("/")[-1] if filename not in files: files.append(filename) return files[:3] @staticmethod def _get_action(diff: str) -> str: if "new file:" in diff: return "add" if "delete file:" in diff: return "remove" if "rename" in diff: return "rename" return "update" def validate_commit_message(message: str) -> tuple[bool, list[str]]: errors = ConventionalCommitParser.validate(message) return len(errors) == 0, errors def format_conventional(message: str, commit_type: Optional[str] = None, scope: Optional[str] = None) -> str: message = message.strip() if not commit_type: return message type_str = commit_type if scope: type_str += f"({scope})" if message and not message.startswith(f"{type_str}:"): return f"{type_str}: {message}" return message def extract_conventional_parts(message: str) -> dict: result = {"type": None, "scope": None, "description": message} parsed = ConventionalCommitParser.parse(message) if parsed: result["type"] = parsed.type result["scope"] = parsed.scope result["description"] = parsed.description return result