From c732e1cb50edd4b6cec4dab1b785e85198170fbe Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Wed, 4 Feb 2026 16:59:23 +0000 Subject: [PATCH] fix: Add Gitea Actions CI workflow and fix linting issues --- src/generator.py | 223 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/generator.py diff --git a/src/generator.py b/src/generator.py new file mode 100644 index 0000000..d9584f4 --- /dev/null +++ b/src/generator.py @@ -0,0 +1,223 @@ +"""Commit message generation logic.""" + +from pathlib import Path +from typing import Optional, Tuple + +from .analyzer import ChangeAnalyzer, ChangeSet, ChangeType +from .config import get_type_rules, load_config +from .templates import format_message + + +class GenerationError(Exception): + """Raised when commit message generation fails.""" + pass + + +def detect_commit_type( + change_set: ChangeSet, + type_rules: Optional[dict] = None +) -> str: + """Detect the commit type based on staged changes. + + Args: + change_set: Collection of staged changes. + type_rules: Optional custom type rules dictionary. + + Returns: + Detected commit type string. + """ + if type_rules is None: + type_rules = get_type_rules() + + scores = {type_: 0 for type_ in type_rules.keys()} + scores["chore"] = 0 + + for change in change_set.changes: + path = change.path.lower() + for type_, patterns in type_rules.items(): + for pattern in patterns: + if pattern.startswith("."): + if path.endswith(pattern): + scores[type_] += 1 + elif "/" in pattern or pattern.startswith("."): + if pattern in path or path.startswith(pattern): + scores[type_] += 1 + else: + if pattern in path: + scores[type_] += 1 + + scores["chore"] = sum(1 for c in change_set.changes if c.change_type in [ + ChangeType.DELETED, ChangeType.TYPE_CHANGE + ]) + + if not scores or all(v == 0 for v in scores.values()): + return "chore" + + return max(scores, key=scores.get) + + +def detect_scope( + change_set: ChangeSet, + scope_overrides: Optional[dict] = None +) -> str: + """Detect the commit scope based on staged changes. + + Args: + change_set: Collection of staged changes. + scope_overrides: Optional custom scope mapping. + + Returns: + Detected scope string or empty string. + """ + if not change_set.has_changes: + return "" + + if scope_overrides is None: + config = load_config() + scope_overrides = config.get("scopes", {}) + + paths = [c.path for c in change_set.changes] + + common_parts = [] + for parts in (p.split("/") for p in paths): + if not common_parts: + common_parts = parts + else: + common_parts = [ + common_parts[i] if ( + i < len(common_parts) and + i < len(parts) and + common_parts[i] == parts[i] + ) else None + for i in range(min(len(common_parts), len(parts))) + ] + common_parts = [p for p in common_parts if p is not None] + + if len(common_parts) <= 1: + candidates = set() + for path in paths: + parts = path.split("/") + if len(parts) > 1: + candidates.add(parts[0]) + + for path in paths: + for scope, match in scope_overrides.items(): + if match in path: + return scope + + if len(candidates) == 1: + return candidates.pop() + + if len(candidates) > 1: + return ",".join(sorted(candidates)[:2]) + + return "" + + scope = common_parts[0] if common_parts else "" + return scope + + +def generate_description(change_set: ChangeSet) -> str: + """Generate a description based on changes. + + Args: + change_set: Collection of staged changes. + + Returns: + Generated description string. + """ + if not change_set.has_changes: + return "update" + + added = change_set.added + deleted = change_set.deleted + modified = change_set.modified + + if deleted: + return f"remove {Path(deleted[0].path).name}" + elif added and len(added) == 1: + return f"add {Path(added[0].path).name}" + elif modified and len(modified) == 1: + return f"update {Path(modified[0].path).name}" + elif added: + return f"add {len(added)} files" + elif modified: + return f"update {len(modified)} files" + else: + return "update" + + +def generate_commit_message( + repo_path: Optional[str] = None, + config_path: Optional[str] = None +) -> str: + """Generate a conventional commit message. + + Args: + repo_path: Optional path to git repository. + config_path: Optional path to configuration file. + + Returns: + Generated commit message string. + + Raises: + GenerationError: If generation fails. + """ + try: + analyzer = ChangeAnalyzer(repo_path) + change_set = analyzer.get_staged_changes() + + if not change_set.has_changes: + raise GenerationError("No staged changes found. Run 'git add' first.") + + config = load_config() if config_path is None else load_config(config_path) + type_rules = config.get("type_rules", {}) + template = config.get("template") + scope_overrides = config.get("scopes", {}) + + commit_type = detect_commit_type(change_set, type_rules) + scope = detect_scope(change_set, scope_overrides) + description = generate_description(change_set) + + files = change_set.file_paths if config.get("include_file_list", True) else None + max_files = config.get("max_files", 5) + if files and len(files) > max_files: + files = files[:max_files] + + message = format_message( + type=commit_type, + scope=scope, + description=description, + template=template, + files=files + ) + + return message + + except ValueError as e: + raise GenerationError(str(e)) + + +def get_commit_message_preview( + repo_path: Optional[str] = None +) -> Tuple[str, bool]: + """Get a preview of the commit message. + + Args: + repo_path: Optional path to git repository. + + Returns: + Tuple of (message, has_changes). + """ + try: + analyzer = ChangeAnalyzer(repo_path) + change_set = analyzer.get_staged_changes() + + if not change_set.has_changes: + return ("", False) + + message = generate_commit_message(repo_path) + return (message, True) + + except GenerationError: + return ("", False)