Initial upload of auto-changelog-generator
This commit is contained in:
253
src/changeloggen/changelog_generator.py
Normal file
253
src/changeloggen/changelog_generator.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user