Initial upload of auto-changelog-generator
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-01-29 12:00:28 +00:00
parent 886b4ca66d
commit c3a2052047

View 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()