refactor(devgen): enhance AI commit message sanitization
The `sanitize_ai_commit_message` function has been significantly improved to better handle AI-generated commit messages, including support for bolded headers, more robust header detection, and a fallback mechanism for non-conventional messages. **Enhancements & Refinements:** - Added comprehensive docstring explaining the function's purpose and behavior - Improved regex pattern to match conventional commit headers with optional bolding (e.g., **feat: ...**) - Added case-insensitive matching for commit types - Implemented a state-based approach with `found_header` flag for clearer logic - Added automatic removal of bold markers (**) from headers - Introduced fallback behavior to return the first non-empty line if no conventional header is found - Improved code structure with better variable naming and comments - Added early return for empty input strings **Bug Fixes:** - Fixed potential issue where the function could return empty strings for non-conventional messages - Resolved edge case handling for messages without proper headers ```
This commit is contained in:
3 files changed
+124
-37
No files matched your search
@@ -26,6 +26,20 @@ class CommitEngineError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# Token Optimization Constants
|
||||
MAX_DIFF_SIZE = 8000 # Maximum characters for a single group diff
|
||||
IGNORE_PATTERNS = [
|
||||
"uv.lock",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"poetry.lock",
|
||||
]
|
||||
|
||||
|
||||
|
||||
class CommitEngine:
|
||||
"""
|
||||
Engine for generating AI-powered commit messages.
|
||||
@@ -72,6 +86,7 @@ class CommitEngine:
|
||||
def detect_changes(self) -> List[str]:
|
||||
"""Detects changed, deleted, or untracked files."""
|
||||
try:
|
||||
# Get modified, deleted and untracked files
|
||||
out = run_git_command(
|
||||
[
|
||||
"git",
|
||||
@@ -82,7 +97,15 @@ class CommitEngine:
|
||||
"--exclude-standard",
|
||||
]
|
||||
)
|
||||
return [f.strip() for f in out.split("\n") if f.strip()]
|
||||
files = [f.strip() for f in out.split("\n") if f.strip()]
|
||||
|
||||
# Also get staged files (in case of a previous failed run)
|
||||
staged_out = run_git_command(["git", "diff", "--name-only", "--cached"])
|
||||
staged_files = [f.strip() for f in staged_out.split("\n") if f.strip()]
|
||||
|
||||
# Combine and deduplicate
|
||||
all_files = list(set(files + staged_files))
|
||||
return all_files
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
self.logger.error(msg)
|
||||
@@ -98,11 +121,38 @@ class CommitEngine:
|
||||
return groups
|
||||
|
||||
def generate_diff(self, files: List[str]) -> str:
|
||||
"""Generates diff for specific files."""
|
||||
"""Generates diff for specific files, with truncation for token optimization."""
|
||||
try:
|
||||
return run_git_command(
|
||||
["git", "--no-pager", "diff", "--staged", "--", *files]
|
||||
)
|
||||
# Filter out very large metadata files that don't need full diffs
|
||||
summary_info = []
|
||||
files_to_diff = []
|
||||
for f in files:
|
||||
if any(p in f for p in IGNORE_PATTERNS):
|
||||
summary_info.append(f"[METADATA UPDATED] {f}")
|
||||
else:
|
||||
files_to_diff.append(f)
|
||||
|
||||
diff = ""
|
||||
if files_to_diff:
|
||||
diff = run_git_command(
|
||||
["git", "--no-pager", "diff", "--staged", "--", *files_to_diff]
|
||||
)
|
||||
|
||||
full_content = "\n".join(summary_info + [diff]).strip()
|
||||
|
||||
# Truncate if too large
|
||||
if len(full_content) > MAX_DIFF_SIZE:
|
||||
self.logger.info(
|
||||
f"Truncating diff from {len(full_content)} to {MAX_DIFF_SIZE} chars"
|
||||
)
|
||||
half = MAX_DIFF_SIZE // 2
|
||||
return (
|
||||
full_content[:half]
|
||||
+ "\n\n... [DIFF TRUNCATED FOR TOKEN OPTIMIZATION] ...\n\n"
|
||||
+ full_content[-half:]
|
||||
)
|
||||
|
||||
return full_content
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
self.logger.error(msg)
|
||||
@@ -239,20 +289,30 @@ class CommitEngine:
|
||||
return True
|
||||
|
||||
msg = self.generate_message(group, diff, cache)
|
||||
if not msg:
|
||||
self.logger.error(f"Empty message for {group}")
|
||||
try:
|
||||
if not msg:
|
||||
self.logger.error(f"Empty message for {group}")
|
||||
self._reset_group(files)
|
||||
return False
|
||||
|
||||
if self.dry_run:
|
||||
self._log_dry_run(group, msg)
|
||||
self._reset_group(files)
|
||||
else:
|
||||
self.commit_staged(msg)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to process group {group}: {e}")
|
||||
self._reset_group(files)
|
||||
return False
|
||||
|
||||
if self.dry_run:
|
||||
self._log_dry_run(group, msg)
|
||||
try:
|
||||
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
else:
|
||||
self.commit_staged(msg)
|
||||
|
||||
return True
|
||||
def _reset_group(self, files: List[str]):
|
||||
"""Unstages files for a group."""
|
||||
try:
|
||||
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||
except subprocess.CalledProcessError:
|
||||
pass # Ignore reset errors
|
||||
|
||||
def execute(self):
|
||||
"""Main execution method."""
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
|
||||
Analyze the following git diff for the directory `{{ group_name }}` and generate a detailed semantic release commit message4. Format the output as:
|
||||
1. A summary title in semantic release format (type(scope): summary) — scope can be the directory or 'root'.
|
||||
2. A short summary paragraph if needed.
|
||||
3. Bullet points listing the main changes, grouped as 'New Features', 'Enhancements & Refinements', and 'Bug Fixes' if relevant. Use bold for feature/section names.
|
||||
Analyze the following git diff for `{{ group_name }}` and generate a semantic commit message.
|
||||
Format:
|
||||
1. Title: `<type>(<scope>): <summary>` (scope is `{{ group_name }}` or `root`).
|
||||
2. Optional short summary.
|
||||
3. Bullet points: `New Features`, `Enhancements`, `Bug Fixes`. Use **bold** for section names.
|
||||
{% if use_emoji %}
|
||||
4. Use appropriate emojis for the commit type and sections (e.g., 🚀 for features, 🐛 for bugs).
|
||||
4. Use emojis (e.g., 🚀, 🐛).
|
||||
{% else %}
|
||||
4. Do NOT use any emojis in the commit message.
|
||||
4. No emojis.
|
||||
{% endif %}
|
||||
Output only the commit message, don't include explanations or extra text.
|
||||
|
||||
Here is the git diff for `{{ group_name }}`:
|
||||
Output ONLY the message.
|
||||
|
||||
Diff for `{{ group_name }}`:
|
||||
{{ diff_text }}
|
||||
+38
-10
@@ -31,25 +31,53 @@ def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
|
||||
|
||||
|
||||
def sanitize_ai_commit_message(raw_text: str) -> str:
|
||||
"""
|
||||
Cleans up the AI-generated commit message.
|
||||
It looks for a conventional commit header and extracts everything from there.
|
||||
It handles bolded headers and common AI prefixes.
|
||||
"""
|
||||
if not raw_text:
|
||||
return ""
|
||||
|
||||
lines = raw_text.strip().split("\n")
|
||||
cleaned_lines = []
|
||||
in_block = False
|
||||
# Regex for conventional commit header
|
||||
|
||||
# Regex for conventional commit header (including optional bolding)
|
||||
# Examples:
|
||||
# - feat(root): summary
|
||||
# - **fix: bug fix**
|
||||
# - chore(deps)!: breaking change
|
||||
header_pattern = re.compile(
|
||||
r"^(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*"
|
||||
r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
cleaned_lines = []
|
||||
found_header = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if in_block:
|
||||
if header_pattern.match(stripped) or "**Sponsor**" in line:
|
||||
if not found_header:
|
||||
if header_pattern.match(stripped):
|
||||
found_header = True
|
||||
# Remove bolding if present
|
||||
clean_line = stripped.replace("**", "").strip()
|
||||
cleaned_lines.append(clean_line)
|
||||
else:
|
||||
# If we hit another header or a known separator, we stop (optional, but keep for now)
|
||||
if "**Sponsor**" in line:
|
||||
break
|
||||
cleaned_lines.append(line)
|
||||
elif header_pattern.match(stripped):
|
||||
in_block = True
|
||||
cleaned_lines.append(line)
|
||||
|
||||
return "\n".join(cleaned_lines).strip() if cleaned_lines else ""
|
||||
if cleaned_lines:
|
||||
return "\n".join(cleaned_lines).strip()
|
||||
|
||||
# Fallback: if no conventional commit header found, just take the first non-empty line
|
||||
# to avoid failing completely, but log a warning if possible.
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
return line.strip()
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_markdown_sections(
|
||||
|
||||
Reference in new issue
Block a user