221 lines
8.3 KiB
Python
221 lines
8.3 KiB
Python
"""
|
|
Commit message and changelog generation using AI.
|
|
"""
|
|
|
|
from typing import List, Dict, Optional, Tuple
|
|
from ..shared.ai import AIService
|
|
from ..shared.config import Config
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
class CommitGenerator(AIService):
|
|
"""AI-powered commit message and changelog generator."""
|
|
|
|
def __init__(self, config: Config):
|
|
"""Initialize commit generator."""
|
|
super().__init__(config)
|
|
|
|
def generate_commit_message(
|
|
self, diff: str, temperature: Optional[float] = None
|
|
) -> str:
|
|
"""Generate a commit message from a diff."""
|
|
system_prompt = """You are an expert Git assistant trained to write highly effective and conventional commit messages.
|
|
|
|
Your task is to analyze the provided code diff and generate a commit message in the following format:
|
|
|
|
Format: type(scope): description
|
|
- type: One of feat, fix, docs, style, refactor, test, chore
|
|
- scope: Optional but encouraged; represents the part of the codebase affected (e.g. auth, api, db, ui)
|
|
- description: A short, clear explanation of the change
|
|
|
|
Each type has an emoji prefix:
|
|
- ✨ feat: New feature
|
|
- 🐛 fix: Bug fix
|
|
- 📚 docs: Documentation-only change
|
|
- 💅 style: Code formatting (no logic change)
|
|
- ♻️ refactor: Code changes without affecting behavior
|
|
- ✅ test: Test additions/changes
|
|
- 🔧 chore: Maintenance, build, or config tasks
|
|
|
|
Strict rules:
|
|
- Write the entire message in a single line
|
|
- Use imperative mood (e.g. "add" not "added")
|
|
- No file paths, no periods at the end
|
|
- Avoid generic or vague descriptions
|
|
- Don't mention pull requests, issues, or reviewers
|
|
- Focus on the why and impact of the change, not just the what
|
|
|
|
Examples:
|
|
- ✨ feat(auth): add OAuth2 login support
|
|
- 🐛 fix(api): return correct HTTP status for invalid input
|
|
- ♻️ refactor(db): simplify user query joins
|
|
|
|
Output ONLY the commit message. Do not include any comments or explanations."""
|
|
|
|
user_prompt = f"""Generate a single-line conventional commit message for the following code changes:
|
|
|
|
{diff}
|
|
|
|
Identify the most relevant type, a concise scope, and the purpose of the change.
|
|
Output ONLY the commit message in the correct format (with emoji)."""
|
|
|
|
message = self.generate_completion(
|
|
system_prompt, user_prompt, temperature=temperature
|
|
)
|
|
|
|
lines = [line.strip() for line in message.split("\n") if line.strip()]
|
|
|
|
for line in lines:
|
|
if any(
|
|
skip in line.lower()
|
|
for skip in [
|
|
"based on",
|
|
"changes in",
|
|
"can be written",
|
|
"commit message",
|
|
"following",
|
|
"these changes",
|
|
"the changes",
|
|
]
|
|
):
|
|
continue
|
|
if line.startswith("`") or line.startswith("/") or ":" in line:
|
|
continue
|
|
message = line
|
|
break
|
|
else:
|
|
message = lines[0] if lines else "🔧 chore: update code"
|
|
|
|
if not any(
|
|
emoji in message for emoji in ["✨", "🐛", "📚", "💅", "♻️", "✅", "🔧"]
|
|
):
|
|
if message.startswith("feat"):
|
|
message = "✨ " + message
|
|
elif message.startswith("fix"):
|
|
message = "🐛 " + message
|
|
elif message.startswith("docs"):
|
|
message = "📚 " + message
|
|
elif message.startswith("style"):
|
|
message = "💅 " + message
|
|
elif message.startswith("refactor"):
|
|
message = "♻️ " + message
|
|
elif message.startswith("test"):
|
|
message = "✅ " + message
|
|
elif message.startswith("chore"):
|
|
message = "🔧 " + message
|
|
else:
|
|
message = "🔧 " + message # fallback
|
|
|
|
return message
|
|
|
|
def generate_batch_messages(
|
|
self, diffs: Dict[str, str], temperature: Optional[float] = None
|
|
) -> Dict[str, str]:
|
|
"""Generate commit messages for multiple files with intelligent grouping.
|
|
|
|
This method analyzes the diffs to determine if they should be grouped into a single
|
|
commit message or kept separate based on their relationship and scope.
|
|
|
|
Args:
|
|
diffs: Dictionary mapping file paths to their diffs
|
|
temperature: Optional temperature for generation
|
|
|
|
Returns:
|
|
Dictionary mapping file paths to their commit messages
|
|
"""
|
|
if not diffs:
|
|
return {}
|
|
|
|
if len(diffs) == 1:
|
|
file_path, diff = next(iter(diffs.items()))
|
|
return {file_path: self.generate_commit_message(diff, temperature)}
|
|
|
|
# First, analyze the diffs to determine if they should be grouped
|
|
system_prompt = """You are an expert at analyzing code changes and determining their relationships.
|
|
Analyze the provided diffs and determine if they represent related changes that should be grouped together.
|
|
Consider:
|
|
1. Are the changes part of the same feature or fix?
|
|
2. Do they share a common scope or purpose?
|
|
3. Would separating them make the commit history less meaningful?
|
|
|
|
Respond with either:
|
|
- "GROUP" if the changes should be combined into one commit
|
|
- "SEPARATE" if the changes should have individual commit messages
|
|
Include a brief explanation of your reasoning."""
|
|
|
|
# Prepare the diffs for analysis
|
|
analysis_input = "\n\n".join(
|
|
f"File: {file_path}\nChanges:\n{diff}" for file_path, diff in diffs.items()
|
|
)
|
|
|
|
analysis = self.generate_completion(
|
|
system_prompt,
|
|
f"Analyze these changes:\n\n{analysis_input}",
|
|
temperature=0.1, # Lower temperature for more consistent analysis
|
|
)
|
|
|
|
should_group = "GROUP" in analysis.upper()
|
|
|
|
if should_group:
|
|
# Generate a single commit message for all changes
|
|
try:
|
|
# Create a structured diff that maintains file context
|
|
structured_diff = "\n\n".join(
|
|
f"=== Changes in {file_path} ===\n{diff}"
|
|
for file_path, diff in diffs.items()
|
|
)
|
|
|
|
message = self.generate_commit_message(structured_diff, temperature)
|
|
return {file_path: message for file_path in diffs.keys()}
|
|
except Exception as e:
|
|
# Fallback to individual messages if combined generation fails
|
|
print(f"Warning: Failed to generate combined message: {e}")
|
|
should_group = False
|
|
|
|
if not should_group:
|
|
# Generate individual messages for each file
|
|
results = {}
|
|
for file_path, diff in diffs.items():
|
|
try:
|
|
message = self.generate_commit_message(diff, temperature)
|
|
results[file_path] = message
|
|
except Exception as e:
|
|
# Fallback to a basic message if generation fails
|
|
print(f"Warning: Failed to generate message for {file_path}: {e}")
|
|
results[file_path] = "🔧 chore: update code"
|
|
return results
|
|
|
|
def generate_changelog(
|
|
self, commits: List[str], version: str, temperature: Optional[float] = None
|
|
) -> str:
|
|
"""Generate a changelog from a list of commits."""
|
|
system_prompt = """You are a professional release manager generating changelogs from commit messages.
|
|
|
|
Follow these rules:
|
|
- Group entries under clear sections: Added, Changed, Fixed, Removed, Refactored, Tests, Chore, etc.
|
|
- Use past tense (e.g. "Added login flow", not "Add login flow")
|
|
- Write concise but informative summaries
|
|
- Each bullet point should start with a verb (e.g. "Fixed", "Added")
|
|
- Skip commit types like "chore" unless they are user-relevant
|
|
- Format sections like this:
|
|
|
|
## [version]
|
|
### Added
|
|
- Added support for OAuth2 authentication
|
|
|
|
### Fixed
|
|
- Fixed issue where empty input crashed the API
|
|
|
|
Do NOT include raw commit messages. Use the commit messages as input and convert them into user-facing changelog entries."""
|
|
|
|
user_prompt = (
|
|
f"Generate a clean and structured changelog for version {
|
|
version
|
|
} using these commits:\n\n"
|
|
+ "\n".join(commits)
|
|
)
|
|
|
|
return self.generate_completion(
|
|
system_prompt, user_prompt, temperature=temperature
|
|
)
|