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