diff --git a/src/changeloggen/changelog_generator.py b/src/changeloggen/changelog_generator.py new file mode 100644 index 0000000..4f2451f --- /dev/null +++ b/src/changeloggen/changelog_generator.py @@ -0,0 +1,253 @@ +from datetime import datetime +from typing import Optional +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.table import Table +from rich.markdown import Markdown + +from .git_client import ChangeSet, FileChange +from .llm_client import CategorizedChange, LLMResponse, OllamaAPIClient, build_categorization_prompt, Config as LLMConfig + + +CONVENTIONAL_TYPES = { + 'feat': ('Features', '✨'), + 'fix': ('Bug Fixes', '🐛'), + 'docs': ('Documentation', '📝'), + 'breaking': ('Breaking Changes', '💥'), + 'refactor': ('Refactoring', '♻️'), + 'style': ('Styles', '💄'), + 'test': ('Tests', '✅'), + 'chore': ('Chore', '🔧'), +} + + +def categorize_changes( + changes: ChangeSet, + llm_client: Optional[OllamaAPIClient] = None, + model: str = "llama3.2" +) -> LLMResponse: + """Categorize git changes using LLM.""" + if llm_client is None: + config = LLMConfig(model=model) + llm_client = OllamaAPIClient(config) + + changes_text = _format_changes_for_llm(changes) + prompt = build_categorization_prompt(changes_text) + + response_text = llm_client.generate(prompt) + + return _parse_llm_response(response_text) + + +def _format_changes_for_llm(changes: ChangeSet) -> str: + """Format changes into text for LLM processing.""" + lines = [] + + if changes.staged_changes: + lines.append("=== STAGED CHANGES ===") + for change in changes.staged_changes: + lines.append(f"\nFile: {change.file_path}") + lines.append(f"Type: {change.change_type}") + lines.append(f"Changes:\n{change.diff_content}") + + if changes.unstaged_changes: + lines.append("\n=== UNSTAGED CHANGES ===") + for change in changes.unstaged_changes: + lines.append(f"\nFile: {change.file_path}") + lines.append(f"Type: {change.change_type}") + lines.append(f"Changes:\n{change.diff_content}") + + return '\n'.join(lines) + + +def _parse_llm_response(response_text: str) -> LLMResponse: + """Parse LLM response into structured data.""" + import json + + try: + json_start = response_text.find('{') + json_end = response_text.rfind('}') + 1 + json_str = response_text[json_start:json_end] + data = json.loads(json_str) + + changes = [] + for item in data.get('changes', []): + changes.append(CategorizedChange( + type=item.get('type', 'chore'), + scope=item.get('scope'), + description=item.get('description', ''), + file_path=item.get('file_path', ''), + breaking_change=item.get('breaking_change', False), + breaking_description=item.get('breaking_description'), + confidence=item.get('confidence', 0.0) + )) + + return LLMResponse( + changes=changes, + summary=data.get('summary', ''), + version=data.get('version', '1.0.0'), + breaking_changes=data.get('breaking_changes', []), + contributors=data.get('contributors', []) + ) + except (json.JSONDecodeError, AttributeError, KeyError) as e: + return LLMResponse( + changes=[], + summary=f"Failed to parse LLM response: {str(e)}", + breaking_changes=[] + ) + + +def group_changes_by_type(changes: list[CategorizedChange]) -> dict[str, list[CategorizedChange]]: + """Group categorized changes by type.""" + grouped = {} + for change in changes: + change_type = change.type.lower() + if change_type not in grouped: + grouped[change_type] = [] + grouped[change_type].append(change) + return grouped + + +def format_conventional_changelog( + categorized: LLMResponse, + version: str = "1.0.0", + date: Optional[str] = None +) -> str: + """Generate markdown changelog following conventional commits format.""" + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + + lines = [] + lines.append(f"# Changelog v{version} ({date})") + lines.append("") + + if categorized.breaking_changes: + lines.append("## 💥 Breaking Changes") + lines.append("") + for bc in categorized.breaking_changes: + lines.append(f"- {bc}") + lines.append("") + + grouped = group_changes_by_type(categorized.changes) + + for change_type, (display_name, emoji) in CONVENTIONAL_TYPES.items(): + if change_type == 'breaking': + continue + if change_type in grouped: + lines.append(f"## {emoji} {display_name}") + lines.append("") + for change in grouped[change_type]: + scope_part = f"({change.scope})" if change.scope else "" + lines.append(f"- **{change.type}{scope_part}:** {change.description}") + lines.append("") + + if categorized.summary: + lines.append("---") + lines.append("") + lines.append("### Summary") + lines.append("") + lines.append(categorized.summary) + + return '\n'.join(lines) + + +def format_json_output(categorized: LLMResponse, version: str = "1.0.0") -> str: + """Export changelog as structured JSON.""" + import json + + output = { + "version": version, + "generated_at": datetime.now().isoformat(), + "summary": categorized.summary, + "changes": [ + { + "type": c.type, + "scope": c.scope, + "description": c.description, + "file_path": c.file_path, + "breaking_change": c.breaking_change, + "breaking_description": c.breaking_description, + "confidence": c.confidence + } + for c in categorized.changes + ], + "breaking_changes": categorized.breaking_changes, + "contributors": categorized.contributors + } + + return json.dumps(output, indent=2) + + +def format_release_notes( + categorized: LLMResponse, + version: str = "1.0.0", + repo_url: Optional[str] = None +) -> str: + """Generate release notes for GitHub/GitLab.""" + lines = [] + lines.append(f"## Release v{version}") + lines.append("") + + if categorized.breaking_changes: + lines.append("### ⚠️ Breaking Changes") + lines.append("") + for bc in categorized.breaking_changes: + lines.append(f"- {bc}") + lines.append("") + + grouped = group_changes_by_type(categorized.changes) + + for change_type, (display_name, emoji) in CONVENTIONAL_TYPES.items(): + if change_type == 'breaking': + continue + if change_type in grouped: + lines.append(f"### {emoji} {display_name}") + lines.append("") + for change in grouped[change_type]: + scope_part = f"**({change.scope})** " if change.scope else "" + lines.append(f"- {scope_part}{change.description}") + lines.append("") + + if categorized.contributors: + lines.append("### Contributors") + lines.append("") + lines.append("Thank you to our contributors:") + lines.append("") + for contributor in categorized.contributors: + lines.append(f"- {contributor}") + lines.append("") + + if categorized.summary: + lines.append("---") + lines.append("") + lines.append(f"**{categorized.summary}**") + + return '\n'.join(lines) + + +def print_changelog_pretty(categorized: LLMResponse, console: Optional[Console] = None): + """Print changelog using Rich for pretty formatting.""" + if console is None: + console = Console() + + grouped = group_changes_by_type(categorized.changes) + + for change_type, (display_name, emoji) in CONVENTIONAL_TYPES.items(): + if change_type == 'breaking': + continue + if change_type in grouped: + table = Table(title=f"{emoji} {display_name}", show_header=True) + table.add_column("Scope", style="cyan") + table.add_column("Description", style="green") + table.add_column("File", style="yellow") + + for change in grouped[change_type]: + table.add_row( + change.scope or "-", + change.description, + change.file_path + ) + + console.print(table) + console.print()