Files
DevGen/devgen/modules/changelog_generator.py
T
sankalp c50f8a4b89 refactor(modules): Standardize git command execution
This commit refactors the way git commands are executed across the `changelog_generator` and `commit_generator` modules, leveraging a shared utility function and enhancing error handling.

*   **Enhancements & Refinements**
    *   Replaced the internal `_exec_git` method in both `ChangelogGenerator` and `CommitEngine` with the common `run_git_command` utility from `devgen.utils`.
    *   Standardized and improved error handling for git command failures by wrapping `run_git_command` calls in `try-except subprocess.CalledProcessError` blocks, providing more precise logging and raising `CommitEngineError` where applicable.
    *   Adjusted error handling for `git reset HEAD` operations to gracefully ignore potential failures, ensuring smoother execution for non-critical resets.
    *   Refined the `is_ahead_of_remote` check in `CommitEngine` to correctly handle scenarios with no upstream configured by using `check=False` for `run_git_command` and broadening error catch.
2025-12-13 21:19:18 +05:45

179 lines
6.3 KiB
Python

import re
import subprocess
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from devgen.utils import configure_logger, run_git_command
class ChangelogGenerator:
"""Generates a changelog from git history using Semantic Release style."""
def __init__(self, logger=None):
self.logger = logger or configure_logger("devgen.changelog")
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
"""Fetches commit messages in the specified range."""
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
# Format: hash|author|date|subject|body
fmt = "%H|%an|%ad|%s|%b"
cmd = ["git", "log", f"--format={fmt}", "--date=short", range_spec]
if not from_ref:
# If no start ref, try to find the last tag
try:
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
cmd = [
"git",
"log",
f"--format={fmt}",
"--date=short",
f"{last_tag}..HEAD",
]
self.logger.info(f"Generating changelog from last tag: {last_tag}")
except (RuntimeError, subprocess.CalledProcessError):
self.logger.info("No tags found, generating for all commits.")
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
try:
return run_git_command(cmd).split("\n")
except subprocess.CalledProcessError as e:
self.logger.error(f"Git command failed: {e}")
raise RuntimeError(f"Git command failed: {e}")
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
"""Parses raw commit strings into structured data."""
groups = defaultdict(list)
# Conventional Commit Regex: type(scope)!: subject
cc_pattern = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
for line in raw_commits:
if not line.strip():
continue
parts = line.split("|", 4)
if len(parts) < 4:
continue
commit_hash, author, date, subject, body = (
parts[0],
parts[1],
parts[2],
parts[3],
parts[4] if len(parts) > 4 else "",
)
match = cc_pattern.match(subject)
if match:
c_type, c_scope, breaking, c_subject = match.groups()
entry = {
"hash": commit_hash,
"author": author,
"date": date,
"scope": c_scope,
"subject": c_subject,
"body": body,
"breaking": bool(breaking),
}
if breaking:
groups["BREAKING CHANGES"].append(entry)
if c_type in ["feat", "feature"]:
groups["Features"].append(entry)
elif c_type in ["fix", "bug"]:
groups["Bug Fixes"].append(entry)
elif c_type in ["docs"]:
groups["Documentation"].append(entry)
elif c_type in [
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
]:
groups["Other Changes"].append(entry)
else:
groups["Other Changes"].append(entry)
else:
# Non-conventional commits
groups["Other Changes"].append(
{
"hash": commit_hash,
"author": author,
"date": date,
"scope": None,
"subject": subject,
"body": body,
"breaking": False,
}
)
return groups
def generate_markdown(
self, groups: Dict[str, List[Dict]], version: str = "Unreleased"
) -> str:
"""Generates markdown changelog from parsed commits."""
date_str = datetime.now().strftime("%Y-%m-%d")
md = [f"# {version} ({date_str})\n"]
# Order: Breaking, Features, Fixes, Docs, Others
order = [
"BREAKING CHANGES",
"Features",
"Bug Fixes",
"Documentation",
"Other Changes",
]
emoji_map = {
"BREAKING CHANGES": "💥 BREAKING CHANGES",
"Features": "✨ Features",
"Bug Fixes": "🐛 Bug Fixes",
"Documentation": "📚 Documentation",
"Other Changes": "🔨 Other Changes",
}
for section in order:
commits = groups.get(section)
if commits:
header = emoji_map.get(section, section)
md.append(f"## {header}\n")
for c in commits:
scope = f"**{c['scope']}**: " if c["scope"] else ""
md.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
md.append("")
return "\n".join(md)
def run(self, output_file: Optional[str] = "CHANGELOG.md", from_ref: str = ""):
"""Main execution method."""
raw_commits = self.get_commits(from_ref)
if not raw_commits or not raw_commits[0]:
self.logger.warning("No commits found.")
return
parsed = self.parse_commits(raw_commits)
md_content = self.generate_markdown(parsed)
if output_file:
path = Path(output_file)
# If file exists, prepend? For now, just overwrite or append logic could be complex.
# Let's implement prepend logic if file exists.
if path.exists():
old_content = path.read_text(encoding="utf-8")
new_content = old_content + "\n\n" + md_content
path.write_text(new_content, encoding="utf-8")
else:
path.write_text(md_content, encoding="utf-8")
self.logger.info(f"Changelog written to {output_file}")
print(f" Changelog updated: {output_file}")
else:
print(md_content)