diff --git a/app/src/git_commit_generator/changelog_generator.py b/app/src/git_commit_generator/changelog_generator.py new file mode 100644 index 0000000..4de636a --- /dev/null +++ b/app/src/git_commit_generator/changelog_generator.py @@ -0,0 +1,170 @@ +"""Changelog generation from git history.""" +from datetime import datetime +from typing import Optional + +from git_commit_generator.config import Config, get_config +from git_commit_generator.git_utils import GitUtils, get_git_utils +from git_commit_generator.ollama_client import OllamaClient, get_ollama_client + + +class ChangelogGenerator: + """Generates CHANGELOG.md from git history.""" + + CHANGE_TYPES = { + "feat": ("Features", "green"), + "fix": ("Bug Fixes", "red"), + "docs": ("Documentation", "blue"), + "style": ("Code Style", "gray"), + "refactor": ("Refactoring", "magenta"), + "perf": ("Performance", "yellow"), + "test": ("Tests", "cyan"), + "build": ("Build System", "white"), + "ci": ("CI/CD", "white"), + "chore": ("Maintenance", "gray"), + "revert": ("Reverts", "red"), + } + + def __init__( + self, + config: Optional[Config] = None, + ollama_client: Optional[OllamaClient] = None, + repo_path: Optional[str] = None, + ): + """Initialize changelog generator.""" + self.config = config or get_config() + self.ollama_client = ollama_client or OllamaClient( + host=self.config.ollama_host, + model=self.config.ollama_model + ) + self.git_utils = GitUtils(repo_path) + + def generate( + self, + since: Optional[str] = None, + limit: int = 50, + output_path: Optional[str] = None, + ) -> str: + """Generate a changelog.""" + commits = self.git_utils.get_commit_history(since=since, limit=limit) + + if not commits: + raise ValueError("No commits found in repository.") + + prompt = self.config.read_prompt("changelog.txt") + if not prompt: + prompt = self._get_default_changelog_prompt() + + commits_text = self._format_commits_for_prompt(commits) + changelog = self.ollama_client.generate_changelog( + commits=commits_text, + prompt=prompt, + model=self.config.ollama_model, + ) + + changelog = self._clean_changelog(changelog) + + if output_path: + self._write_changelog(output_path, changelog) + + return changelog + + def _get_default_changelog_prompt(self) -> str: + """Get default changelog prompt.""" + return """Generate a changelog in markdown format from the following commits. +Group by type (feat, fix, docs, etc.) and format properly.""" + + def _format_commits_for_prompt(self, commits: list[dict]) -> str: + """Format commits for the LLM prompt.""" + lines = [] + for commit in commits: + lines.append( + f"[{commit['hash']}] {commit['message']} ({commit['author']}, {commit['date']})" + ) + return "\n".join(lines) + + def _clean_changelog(self, changelog: str) -> str: + """Clean and normalize changelog.""" + lines = changelog.strip().split("\n") + + cleaned_lines = [] + for line in lines: + line = line.strip() + if line and not line.startswith("```"): + cleaned_lines.append(line) + + return "\n".join(cleaned_lines) + + def _write_changelog(self, path: str, changelog: str) -> None: + """Write changelog to file.""" + with open(path, "w") as f: + f.write(f"# Changelog\n\nGenerated on {datetime.now().isoformat()}\n\n") + f.write(changelog) + + def generate_simple( + self, + since: Optional[str] = None, + limit: int = 50, + output_path: Optional[str] = None, + ) -> str: + """Generate a simple changelog without LLM.""" + commits = self.git_utils.get_conventional_commits(since=since, limit=limit) + + if not commits: + raise ValueError("No conventional commits found in repository.") + + grouped = self._group_commits_by_type(commits) + + changelog = self._format_simple_changelog(grouped) + + if output_path: + self._write_changelog(output_path, changelog) + + return changelog + + def _group_commits_by_type(self, commits: list[dict]) -> dict: + """Group commits by their type.""" + grouped = {} + for commit in commits: + commit_type = commit.get("type", "chore") + if commit_type not in grouped: + grouped[commit_type] = [] + grouped[commit_type].append(commit) + return grouped + + def _format_simple_changelog(self, grouped: dict) -> str: + """Format grouped commits into markdown changelog.""" + lines = ["# Changelog\n"] + + type_order = [ + "feat", "fix", "perf", "refactor", "docs", + "style", "test", "build", "ci", "chore", "revert" + ] + + for commit_type in type_order: + if commit_type in grouped: + type_name, _ = self.CHANGE_TYPES.get( + commit_type, (commit_type.title(), "white") + ) + lines.append(f"\n## {type_name}\n") + for commit in grouped[commit_type]: + scope = commit.get("scope", "") + description = commit.get("description", "") + if scope: + lines.append(f"- **{commit_type}({scope}):** {description}") + else: + lines.append(f"- **{commit_type}:** {description}") + + return "\n".join(lines) + + +def get_changelog_generator( + config: Optional[Config] = None, + ollama_client: Optional[OllamaClient] = None, + repo_path: Optional[str] = None, +) -> ChangelogGenerator: + """Get ChangelogGenerator instance.""" + return ChangelogGenerator( + config=config, + ollama_client=ollama_client, + repo_path=repo_path, + )