355 lines
11 KiB
Python
Executable File
355 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Script: subject-generator.py
|
|
# Purpose: Generate conventional commit subject line with validation
|
|
# Author: Git Commit Assistant Plugin
|
|
# Version: 1.0.0
|
|
#
|
|
# Usage:
|
|
# echo '{"type":"feat","scope":"auth","description":"add OAuth"}' | ./subject-generator.py
|
|
# cat input.json | ./subject-generator.py
|
|
#
|
|
# Returns:
|
|
# JSON: {"subject": "feat(auth): add OAuth", "length": 22, "warnings": [], "suggestions": []}
|
|
#
|
|
# Exit Codes:
|
|
# 0 - Success
|
|
# 1 - Invalid input
|
|
# 2 - Validation error
|
|
|
|
import sys
|
|
import json
|
|
import re
|
|
|
|
def enforce_imperative_mood(text):
|
|
"""Convert common non-imperative forms to imperative mood."""
|
|
|
|
# Common past tense to imperative conversions
|
|
conversions = {
|
|
r'\badded\b': 'add',
|
|
r'\bfixed\b': 'fix',
|
|
r'\bupdated\b': 'update',
|
|
r'\bremoved\b': 'remove',
|
|
r'\bchanged\b': 'change',
|
|
r'\bimproved\b': 'improve',
|
|
r'\brefactored\b': 'refactor',
|
|
r'\bimplemented\b': 'implement',
|
|
r'\bcreated\b': 'create',
|
|
r'\bdeleted\b': 'delete',
|
|
r'\bmodified\b': 'modify',
|
|
r'\boptimized\b': 'optimize',
|
|
r'\bmoved\b': 'move',
|
|
r'\brenamed\b': 'rename',
|
|
r'\bcleaned\b': 'clean',
|
|
r'\bintroduced\b': 'introduce',
|
|
}
|
|
|
|
# Present tense (3rd person) to imperative
|
|
present_conversions = {
|
|
r'\badds\b': 'add',
|
|
r'\bfixes\b': 'fix',
|
|
r'\bupdates\b': 'update',
|
|
r'\bremoves\b': 'remove',
|
|
r'\bchanges\b': 'change',
|
|
r'\bimproves\b': 'improve',
|
|
r'\brefactors\b': 'refactor',
|
|
r'\bimplements\b': 'implement',
|
|
r'\bcreates\b': 'create',
|
|
r'\bdeletes\b': 'delete',
|
|
r'\bmodifies\b': 'modify',
|
|
r'\boptimizes\b': 'optimize',
|
|
r'\bmoves\b': 'move',
|
|
r'\brenames\b': 'rename',
|
|
r'\bcleans\b': 'clean',
|
|
r'\bintroduces\b': 'introduce',
|
|
}
|
|
|
|
original = text
|
|
|
|
# Apply conversions
|
|
for pattern, replacement in conversions.items():
|
|
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
|
|
|
for pattern, replacement in present_conversions.items():
|
|
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
|
|
|
# Track if changes were made
|
|
changed = (original != text)
|
|
|
|
return text, changed
|
|
|
|
def check_capitalization(text):
|
|
"""Check if description starts with lowercase (should not be capitalized)."""
|
|
if not text:
|
|
return True, []
|
|
|
|
warnings = []
|
|
if text[0].isupper():
|
|
warnings.append({
|
|
'type': 'capitalization',
|
|
'message': 'Description should start with lowercase',
|
|
'current': text,
|
|
'suggested': text[0].lower() + text[1:]
|
|
})
|
|
return False, warnings
|
|
|
|
return True, warnings
|
|
|
|
def check_period_at_end(text):
|
|
"""Check if description ends with period (should not)."""
|
|
warnings = []
|
|
if text.endswith('.'):
|
|
warnings.append({
|
|
'type': 'punctuation',
|
|
'message': 'Subject should not end with period',
|
|
'current': text,
|
|
'suggested': text[:-1]
|
|
})
|
|
return False, warnings
|
|
|
|
return True, warnings
|
|
|
|
def shorten_description(description, max_length, type_scope_part):
|
|
"""Attempt to shorten description to fit within max_length."""
|
|
|
|
# Calculate available space for description
|
|
prefix_length = len(type_scope_part) + 2 # +2 for ": "
|
|
available_length = max_length - prefix_length
|
|
|
|
if len(description) <= available_length:
|
|
return description, []
|
|
|
|
suggestions = []
|
|
|
|
# Strategy 1: Remove filler words
|
|
filler_words = ['a', 'an', 'the', 'some', 'very', 'really', 'just', 'quite']
|
|
shortened = description
|
|
for word in filler_words:
|
|
shortened = re.sub(r'\b' + word + r'\b\s*', '', shortened, flags=re.IGNORECASE)
|
|
shortened = shortened.strip()
|
|
if len(shortened) <= available_length:
|
|
suggestions.append({
|
|
'strategy': 'remove_filler',
|
|
'description': shortened,
|
|
'saved': len(description) - len(shortened)
|
|
})
|
|
return shortened, suggestions
|
|
|
|
# Strategy 2: Truncate with ellipsis (not recommended but possible)
|
|
if available_length > 3:
|
|
truncated = description[:available_length - 3] + '...'
|
|
suggestions.append({
|
|
'strategy': 'truncate',
|
|
'description': truncated,
|
|
'warning': 'Truncation loses information - consider moving details to body'
|
|
})
|
|
|
|
# Strategy 3: Suggest moving to body
|
|
suggestions.append({
|
|
'strategy': 'move_to_body',
|
|
'description': description[:available_length],
|
|
'remaining': description[available_length:],
|
|
'warning': 'Move detailed information to commit body'
|
|
})
|
|
|
|
return description, suggestions
|
|
|
|
def generate_subject(data):
|
|
"""
|
|
Generate commit subject line from input data.
|
|
|
|
Args:
|
|
data: dict with keys: type, scope (optional), description, max_length (optional)
|
|
|
|
Returns:
|
|
dict with subject, length, warnings, suggestions
|
|
"""
|
|
|
|
# Extract parameters
|
|
commit_type = data.get('type', '').strip().lower()
|
|
scope = data.get('scope', '').strip()
|
|
description = data.get('description', '').strip()
|
|
max_length = int(data.get('max_length', 50))
|
|
|
|
# Validate required fields
|
|
if not commit_type:
|
|
return {
|
|
'error': 'type is required',
|
|
'subject': None
|
|
}
|
|
|
|
if not description:
|
|
return {
|
|
'error': 'description is required',
|
|
'subject': None
|
|
}
|
|
|
|
# Validate type
|
|
valid_types = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']
|
|
if commit_type not in valid_types:
|
|
return {
|
|
'error': f'Invalid type "{commit_type}". Valid types: {", ".join(valid_types)}',
|
|
'subject': None
|
|
}
|
|
|
|
# Enforce imperative mood
|
|
original_description = description
|
|
description, mood_changed = enforce_imperative_mood(description)
|
|
|
|
# Ensure lowercase after colon
|
|
if description and description[0].isupper():
|
|
description = description[0].lower() + description[1:]
|
|
|
|
# Remove period at end
|
|
if description.endswith('.'):
|
|
description = description[:-1]
|
|
|
|
# Build type(scope) part
|
|
if scope:
|
|
type_scope_part = f"{commit_type}({scope})"
|
|
else:
|
|
type_scope_part = commit_type
|
|
|
|
# Build initial subject
|
|
subject = f"{type_scope_part}: {description}"
|
|
subject_length = len(subject)
|
|
|
|
# Collect warnings and suggestions
|
|
warnings = []
|
|
suggestions = []
|
|
|
|
# Check mood change
|
|
if mood_changed:
|
|
warnings.append({
|
|
'type': 'mood',
|
|
'message': 'Changed to imperative mood',
|
|
'original': original_description,
|
|
'corrected': description
|
|
})
|
|
|
|
# Check length
|
|
if subject_length > max_length:
|
|
warnings.append({
|
|
'type': 'length',
|
|
'message': f'Subject exceeds {max_length} characters ({subject_length} chars)',
|
|
'length': subject_length,
|
|
'max': max_length,
|
|
'excess': subject_length - max_length
|
|
})
|
|
|
|
# Try to shorten
|
|
shortened_desc, shorten_suggestions = shorten_description(description, max_length, type_scope_part)
|
|
if shorten_suggestions:
|
|
suggestions.extend(shorten_suggestions)
|
|
|
|
# If we successfully shortened, update subject
|
|
if len(shortened_desc) < len(description):
|
|
alternative_subject = f"{type_scope_part}: {shortened_desc}"
|
|
if len(alternative_subject) <= max_length:
|
|
suggestions.append({
|
|
'type': 'shortened_subject',
|
|
'subject': alternative_subject,
|
|
'saved': subject_length - len(alternative_subject)
|
|
})
|
|
|
|
# Warning if close to limit
|
|
elif subject_length > 45 and max_length == 50:
|
|
suggestions.append({
|
|
'type': 'near_limit',
|
|
'message': f'Subject is close to {max_length} character limit ({subject_length} chars)'
|
|
})
|
|
|
|
# Check for common issues
|
|
if ' and ' in description or ' & ' in description:
|
|
suggestions.append({
|
|
'type': 'multiple_changes',
|
|
'message': 'Subject mentions multiple changes - consider splitting into multiple commits or using bullet points in body'
|
|
})
|
|
|
|
# Check for filler words
|
|
filler_pattern = r'\b(just|very|really|quite|some)\b'
|
|
if re.search(filler_pattern, description, re.IGNORECASE):
|
|
cleaned = re.sub(filler_pattern, '', description, flags=re.IGNORECASE)
|
|
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
|
suggestions.append({
|
|
'type': 'filler_words',
|
|
'message': 'Remove filler words for clarity',
|
|
'current': description,
|
|
'suggested': cleaned
|
|
})
|
|
|
|
# Build response
|
|
response = {
|
|
'subject': subject,
|
|
'length': subject_length,
|
|
'max_length': max_length,
|
|
'type': commit_type,
|
|
'scope': scope if scope else None,
|
|
'description': description,
|
|
'valid': subject_length <= max_length and subject_length <= 72, # 72 is hard limit
|
|
'warnings': warnings,
|
|
'suggestions': suggestions
|
|
}
|
|
|
|
# Add quality score
|
|
score = 100
|
|
if subject_length > max_length:
|
|
score -= 20
|
|
if subject_length > 72:
|
|
score -= 30 # Major penalty for exceeding hard limit
|
|
if mood_changed:
|
|
score -= 5
|
|
if len(warnings) > 0:
|
|
score -= len(warnings) * 3
|
|
|
|
response['quality_score'] = max(0, score)
|
|
|
|
return response
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
|
|
try:
|
|
# Read JSON input from stdin
|
|
input_data = sys.stdin.read()
|
|
|
|
if not input_data or not input_data.strip():
|
|
print(json.dumps({
|
|
'error': 'No input provided',
|
|
'subject': None
|
|
}))
|
|
sys.exit(1)
|
|
|
|
# Parse JSON
|
|
try:
|
|
data = json.loads(input_data)
|
|
except json.JSONDecodeError as e:
|
|
print(json.dumps({
|
|
'error': f'Invalid JSON: {str(e)}',
|
|
'subject': None
|
|
}))
|
|
sys.exit(1)
|
|
|
|
# Generate subject
|
|
result = generate_subject(data)
|
|
|
|
# Output result
|
|
print(json.dumps(result, indent=2))
|
|
|
|
# Exit code based on result
|
|
if 'error' in result:
|
|
sys.exit(2)
|
|
elif not result.get('valid', False):
|
|
sys.exit(1)
|
|
else:
|
|
sys.exit(0)
|
|
|
|
except Exception as e:
|
|
print(json.dumps({
|
|
'error': f'Unexpected error: {str(e)}',
|
|
'subject': None
|
|
}))
|
|
sys.exit(2)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|