diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2be3cc8..99ef481 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.17 +current_version = 1.1.2 commit = True tag = False diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd8f3cc..4d78026 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -64,9 +64,16 @@ repos: language: system types: [yaml] files: ^docker-compose(\.dev|\.prod)?\.yml$ + - id: validate-commit + name: validate-commit + entry: python control_commit/main.py --log-level=DEBUG + always_run: true + pass_filenames: false + language: system + stages: [pre-push] - id: commit-msg-version-check name: commit-msg-version-check - entry: python commit_msg_version_bump/main.py --log-level=DEBUG + entry: python commit_msg_version_bump/main.py --log-level=INFO always_run: true language: system pass_filenames: false @@ -74,13 +81,13 @@ repos: stages: [pre-push] - id: bump-year name: bump-year - entry: python bump_year/main.py + entry: python bump_year/main.py --log-level=INFO always_run: true pass_filenames: false language: system - id: generate-changelog name: generate-changelog - entry: python generate_changelog/main.py + entry: python generate_changelog/main.py --log-level=INFO always_run: true pass_filenames: false language: system diff --git a/commit_msg_version_bump/main.py b/commit_msg_version_bump/main.py index 0a7f37d..d013f03 100644 --- a/commit_msg_version_bump/main.py +++ b/commit_msg_version_bump/main.py @@ -120,6 +120,7 @@ def get_latest_commit_message() -> str: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", ).stdout.strip() logger.debug(f"Latest commit message: {message}") return message @@ -201,7 +202,11 @@ def bump_version(part: str) -> None: subprocess.CalledProcessError: If bump2version fails. """ try: - subprocess.run(["bump2version", part], check=True) + subprocess.run( + ["bump2version", part], + check=True, + encoding="utf-8", + ) logger.info(f"Successfully bumped the {part} version.") except subprocess.CalledProcessError as error: logger.error(f"Failed to bump the {part} version: {error}") @@ -216,7 +221,11 @@ def stage_changes(pyproject_path: str = "pyproject.toml") -> None: pyproject_path (str): Path to the file to stage. """ try: - subprocess.run(["git", "add", pyproject_path], check=True) + subprocess.run( + ["git", "add", pyproject_path], + check=True, + encoding="utf-8", + ) logger.debug(f"Staged {pyproject_path} for commit.") except subprocess.CalledProcessError as e: logger.error(f"Failed to stage {pyproject_path}: {e}") @@ -235,10 +244,14 @@ def amend_commit(new_commit_msg: str) -> None: """ try: # Amend the commit with the new commit message - subprocess.run(["git", "commit", "--amend", "-m", new_commit_msg], check=True) + subprocess.run( + ["git", "commit", "--amend", "-m", new_commit_msg], + check=True, + encoding="utf-8", + ) logger.info("Successfully amended the commit with the new version bump.") logger.info( - "Please perform a force push using 'git push' to update the remote repository. Avoid use --force" + "Please perform a push using 'git push' to update the remote repository. Avoid using --force" ) except subprocess.CalledProcessError as e: logger.error(f"Failed to amend the commit: {e}") @@ -283,7 +296,7 @@ def main() -> None: amend_commit(updated_commit_msg) logger.info( - "Aborting the current push. Please perform a force push using 'git push'. Avoid use --force" + "Aborting the current push. Please perform a push using 'git push'. Avoid using --force" ) sys.exit(1) else: @@ -310,11 +323,7 @@ def determine_version_bump(commit_msg: str) -> Optional[str]: elif "patch" in keyword: return "patch" else: - # Fallback based on commit type - type_match = COMMIT_TYPE_REGEX.match(commit_msg) - if type_match: - commit_type = type_match.group("type").lower() - return VERSION_BUMP_MAPPING.get(commit_type) + return None return None diff --git a/control_commit/main.py b/control_commit/main.py new file mode 100644 index 0000000..42e17e0 --- /dev/null +++ b/control_commit/main.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +control_commit/main.py + +A script to validate commit messages and add appropriate icons based on commit types. +Ensures that commit messages follow a specific structure and naming conventions. +Adds icons to commit messages that do not contain square brackets []. +""" + +import argparse +import logging +import re +import subprocess +import sys +from logging.handlers import RotatingFileHandler + +# Mapping of commit types to icons +TYPE_MAPPING = { + "feat": "✨", + "fix": "🐛", + "docs": "📝", + "style": "💄", + "refactor": "♻️", + "perf": "⚡️", + "test": "✅", + "chore": "🔧", +} + +# Regular expressions for detecting commit types and validating commit message structure +COMMIT_TYPE_REGEX = re.compile(r"^(?Pfeat|fix|docs|style|refactor|perf|test|chore)") +COMMIT_MESSAGE_REGEX = re.compile( + r"^(?Pfeat|fix|docs|style|refactor|perf|test|chore)" + r"(?:\((?P[a-z0-9\-]+)\))?:\s+" + r"(?P[a-z].+)$" +) + +# Initialize the logger +logger = logging.getLogger(__name__) + + +def parse_arguments() -> argparse.Namespace: + """ + Parses command-line arguments. + + Returns: + argparse.Namespace: Parsed arguments. + """ + parser = argparse.ArgumentParser( + description=( + "Validate commit messages and add icons based on commit types. " + "Ensures commit messages follow the format: type(scope): description." + ) + ) + parser.add_argument( + "--log-level", + choices=["INFO", "DEBUG"], + default="INFO", + help="Set the logging level. Default is INFO.", + ) + return parser.parse_args() + + +def configure_logger(log_level: str) -> None: + """ + Configures logging for the script. + + Args: + log_level (str): Logging level as a string (e.g., 'INFO', 'DEBUG'). + """ + numeric_level = getattr(logging, log_level.upper(), None) + if not isinstance(numeric_level, int): + raise ValueError(f"Invalid log level: {log_level}") + + logger.setLevel(numeric_level) + + # Set up log rotation: max size 5MB, keep 5 backup files + file_handler = RotatingFileHandler( + "commit_msg_icon_adder.log", + maxBytes=5 * 1024 * 1024, + backupCount=5, + encoding="utf-8", # Ensure UTF-8 encoding to handle emojis + ) + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + file_handler.setFormatter(formatter) + + # Create a safe console handler that replaces unencodable characters + class SafeStreamHandler(logging.StreamHandler): + def emit(self, record): + try: + msg = self.format(record) + # Replace characters that can't be encoded + msg = msg.encode(self.stream.encoding, errors="replace").decode( + self.stream.encoding + ) + self.stream.write(msg + self.terminator) + self.flush() + except Exception: + self.handleError(record) + + safe_console_handler = SafeStreamHandler() + safe_console_handler.setFormatter(formatter) + + logger.handlers.clear() + logger.addHandler(file_handler) + logger.addHandler(safe_console_handler) + + +def read_commit_message(file_path: str) -> str: + """ + Reads the commit message from the given file. + + Args: + file_path (str): Path to the commit message file. + + Returns: + str: The commit message. + """ + try: + with open(file_path, "r", encoding="utf-8") as file: + commit_msg = file.read().strip() + logger.debug(f"Original commit message: {commit_msg}") + return commit_msg + except FileNotFoundError: + logger.error(f"Commit message file not found: {file_path}") + sys.exit(1) + except Exception as e: + logger.error(f"Error reading commit message file: {e}") + sys.exit(1) + + +def validate_commit_message(commit_msg: str) -> bool: + """ + Validates the commit message against the required structure and lowercase naming. + + Args: + commit_msg (str): The commit message to validate. + + Returns: + bool: True if valid, False otherwise. + """ + match = COMMIT_MESSAGE_REGEX.match(commit_msg) + if match or commit_msg.__contains__("Bump version:"): + logger.debug("Commit message structure is valid.") + return True + else: + logger.error("Invalid commit message structure. Ensure it follows the format:") + logger.error("type(scope): description") + logger.error(" - type: feat, fix, docs, style, refactor, perf, test, chore (lowercase)") + logger.error(" - scope: optional, lowercase, alphanumeric and hyphens") + logger.error(" - description: starts with a lowercase letter") + return False + + +def add_icon_to_commit_message(commit_type: str, existing_commit_msg: str) -> str: + """ + Adds an icon to the commit message based on its type if it doesn't already have one. + + Args: + commit_type (str): The type of the commit (e.g., 'chore', 'fix'). + existing_commit_msg (str): The original commit message. + + Returns: + str: The commit message with the icon prepended. + """ + icon = TYPE_MAPPING.get(commit_type.lower(), "") + if icon and not existing_commit_msg.startswith(icon): + new_commit_msg = f"{icon} {existing_commit_msg}" + logger.debug(f"Updated commit message with icon: {new_commit_msg}") + return new_commit_msg + logger.debug("Icon already present in commit message or no icon defined for commit type.") + return existing_commit_msg + + +def amend_commit(new_commit_msg: str) -> None: + """ + Amends the current commit with the new commit message. + + Args: + new_commit_msg (str): The new commit message. + + Raises: + subprocess.CalledProcessError: If git amend fails. + """ + try: + # Amend the commit with the new commit message + subprocess.run(["git", "commit", "--amend", "-m", new_commit_msg], check=True) + logger.info("Successfully amended the commit with the new commit message.") + logger.info( + "Please perform a push using 'git push' to update the remote repository. Avoid use --force" + ) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to amend the commit: {e}") + sys.exit(1) + + +def has_square_brackets(commit_msg: str) -> bool: + """ + Checks if the commit message contains square brackets. + + Args: + commit_msg (str): The commit message. + + Returns: + bool: True if square brackets are present, False otherwise. + """ + return bool(re.search(r"\[.*?\]", commit_msg)) + + +def main() -> None: + """ + Main function to validate commit messages and add icons if necessary. + Exits with code 1 if validation fails or after adding an icon. + """ + global commit_msg_without_icon + args = parse_arguments() + configure_logger(args.log_level) + + commit_msg_file = ".git/COMMIT_EDITMSG" + commit_msg = read_commit_message(commit_msg_file) + + # Verify if the commit message already starts with an icon + icon_present = False + for icon in TYPE_MAPPING.values(): + if commit_msg.startswith(f"{icon} "): + icon_present = True + commit_msg_without_icon = commit_msg[len(icon) + 1 :] + logger.debug(f"Commit message already has icon '{icon}'.") + break + + if icon_present: + # Validate the commit message without the icon + if not validate_commit_message(commit_msg_without_icon): + logger.error("Commit message validation failed after removing icon. Aborting commit.") + sys.exit(1) + else: + logger.debug("Commit message with icon is valid.") + sys.exit(0) # Valid commit message with icon; proceed + else: + # Validate the original commit message + if not validate_commit_message(commit_msg): + logger.error("Commit message validation failed. Aborting commit.") + sys.exit(1) + logger.debug("Commit message does not contain square brackets. Proceeding to add icon.") + + # Determine the type of commit to get the appropriate icon + type_match = COMMIT_TYPE_REGEX.match(commit_msg) + if type_match: + commit_type = type_match.group("type") + logger.debug(f"Detected commit type: {commit_type}") + else: + commit_type = "chore" # Default to 'chore' if no type is found + logger.debug("No commit type detected. Defaulting to 'chore'.") + sys.exit(1) + + # Add the icon to the existing commit message + updated_commit_msg = add_icon_to_commit_message(commit_type, commit_msg) + + # Write the updated commit message back to the file + amend_commit(updated_commit_msg) + + # Inform the user and abort the commit to allow them to review the amended message + logger.info( + "Commit message has been updated with an icon. Please review and finalize the commit." + ) + sys.exit(1) + + +if __name__ == "__main__": + """""" + main() diff --git a/pyproject.toml b/pyproject.toml index d828076..196513a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scripts" -version = "1.0.17" +version = "1.1.2" description = "CICD Core Scripts" authors = ["B "] license = "Apache 2.0" @@ -71,5 +71,5 @@ ensure_newline_before_comments = true rcfile = ".pylintrc" [build-system] -requires = ["poetry-core>=1.0.17"] +requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"