Initial commit
This commit is contained in:
292
commands/message-generation/.scripts/footer-builder.py
Executable file
292
commands/message-generation/.scripts/footer-builder.py
Executable file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
# Script: footer-builder.py
|
||||
# Purpose: Build commit message footer with breaking changes and issue references
|
||||
# Author: Git Commit Assistant Plugin
|
||||
# Version: 1.0.0
|
||||
#
|
||||
# Usage:
|
||||
# echo '{"breaking":"API changed","closes":"123,456"}' | ./footer-builder.py
|
||||
# cat input.json | ./footer-builder.py
|
||||
#
|
||||
# Returns:
|
||||
# JSON: {"footer": "...", "components": {...}, "valid": true}
|
||||
#
|
||||
# Exit Codes:
|
||||
# 0 - Success
|
||||
# 1 - Invalid input
|
||||
# 2 - Processing error
|
||||
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
def wrap_text(text, width=72, subsequent_indent=''):
|
||||
"""Wrap text at specified width."""
|
||||
wrapper = textwrap.TextWrapper(
|
||||
width=width,
|
||||
subsequent_indent=subsequent_indent,
|
||||
break_long_words=False,
|
||||
break_on_hyphens=False
|
||||
)
|
||||
return wrapper.fill(text)
|
||||
|
||||
def format_breaking_change(description):
|
||||
"""Format breaking change notice."""
|
||||
if not description:
|
||||
return None
|
||||
|
||||
# Ensure BREAKING CHANGE is uppercase
|
||||
# Wrap at 72 characters with continuation indentation
|
||||
wrapped = wrap_text(
|
||||
description,
|
||||
width=72,
|
||||
subsequent_indent=''
|
||||
)
|
||||
|
||||
return f"BREAKING CHANGE: {wrapped}"
|
||||
|
||||
def parse_issue_numbers(issue_string):
|
||||
"""Parse comma-separated issue numbers into list."""
|
||||
if not issue_string:
|
||||
return []
|
||||
|
||||
# Remove any # symbols
|
||||
issue_string = issue_string.replace('#', '')
|
||||
|
||||
# Split by comma and clean
|
||||
issues = [num.strip() for num in issue_string.split(',') if num.strip()]
|
||||
|
||||
# Validate all are numbers
|
||||
valid_issues = []
|
||||
for issue in issues:
|
||||
if issue.isdigit():
|
||||
valid_issues.append(issue)
|
||||
else:
|
||||
# Try to extract number
|
||||
match = re.search(r'\d+', issue)
|
||||
if match:
|
||||
valid_issues.append(match.group())
|
||||
|
||||
return valid_issues
|
||||
|
||||
def format_issue_references(closes=None, fixes=None, refs=None):
|
||||
"""Format issue references."""
|
||||
lines = []
|
||||
|
||||
# Closes (for features/pull requests)
|
||||
if closes:
|
||||
issues = parse_issue_numbers(closes)
|
||||
if issues:
|
||||
if len(issues) == 1:
|
||||
lines.append(f"Closes #{issues[0]}")
|
||||
else:
|
||||
# Format as comma-separated list
|
||||
issue_refs = ', '.join([f"#{num}" for num in issues])
|
||||
lines.append(f"Closes {issue_refs}")
|
||||
|
||||
# Fixes (for bug fixes)
|
||||
if fixes:
|
||||
issues = parse_issue_numbers(fixes)
|
||||
if issues:
|
||||
if len(issues) == 1:
|
||||
lines.append(f"Fixes #{issues[0]}")
|
||||
else:
|
||||
issue_refs = ', '.join([f"#{num}" for num in issues])
|
||||
lines.append(f"Fixes {issue_refs}")
|
||||
|
||||
# Refs (for related issues)
|
||||
if refs:
|
||||
issues = parse_issue_numbers(refs)
|
||||
if issues:
|
||||
if len(issues) == 1:
|
||||
lines.append(f"Refs #{issues[0]}")
|
||||
else:
|
||||
issue_refs = ', '.join([f"#{num}" for num in issues])
|
||||
lines.append(f"Refs {issue_refs}")
|
||||
|
||||
return lines
|
||||
|
||||
def format_metadata(reviewed=None, signed=None):
|
||||
"""Format metadata like Reviewed-by and Signed-off-by."""
|
||||
lines = []
|
||||
|
||||
if reviewed:
|
||||
lines.append(f"Reviewed-by: {reviewed}")
|
||||
|
||||
if signed:
|
||||
# Validate email format
|
||||
if '@' in signed and '<' in signed and '>' in signed:
|
||||
lines.append(f"Signed-off-by: {signed}")
|
||||
else:
|
||||
# Try to format properly
|
||||
lines.append(f"Signed-off-by: {signed}")
|
||||
|
||||
return lines
|
||||
|
||||
def build_footer(data):
|
||||
"""
|
||||
Build commit message footer from input data.
|
||||
|
||||
Args:
|
||||
data: dict with keys: breaking, closes, fixes, refs, reviewed, signed
|
||||
|
||||
Returns:
|
||||
dict with footer, components, valid status
|
||||
"""
|
||||
|
||||
# Extract parameters
|
||||
breaking = data.get('breaking', '').strip()
|
||||
closes = data.get('closes', '').strip()
|
||||
fixes = data.get('fixes', '').strip()
|
||||
refs = data.get('refs', '').strip()
|
||||
reviewed = data.get('reviewed', '').strip()
|
||||
signed = data.get('signed', '').strip()
|
||||
|
||||
# Check if any parameter provided
|
||||
has_content = any([breaking, closes, fixes, refs, reviewed, signed])
|
||||
|
||||
if not has_content:
|
||||
return {
|
||||
'error': 'At least one footer component is required',
|
||||
'footer': None,
|
||||
'valid': False
|
||||
}
|
||||
|
||||
# Build footer components
|
||||
footer_lines = []
|
||||
components = {
|
||||
'breaking_change': False,
|
||||
'closes_issues': 0,
|
||||
'fixes_issues': 0,
|
||||
'refs_issues': 0,
|
||||
'reviewed_by': False,
|
||||
'signed_off': False
|
||||
}
|
||||
|
||||
# Breaking change (always first)
|
||||
if breaking:
|
||||
breaking_line = format_breaking_change(breaking)
|
||||
if breaking_line:
|
||||
footer_lines.append(breaking_line)
|
||||
components['breaking_change'] = True
|
||||
|
||||
# Issue references
|
||||
issue_lines = format_issue_references(closes, fixes, refs)
|
||||
footer_lines.extend(issue_lines)
|
||||
|
||||
# Count issues
|
||||
if closes:
|
||||
components['closes_issues'] = len(parse_issue_numbers(closes))
|
||||
if fixes:
|
||||
components['fixes_issues'] = len(parse_issue_numbers(fixes))
|
||||
if refs:
|
||||
components['refs_issues'] = len(parse_issue_numbers(refs))
|
||||
|
||||
# Metadata
|
||||
metadata_lines = format_metadata(reviewed, signed)
|
||||
footer_lines.extend(metadata_lines)
|
||||
|
||||
if reviewed:
|
||||
components['reviewed_by'] = True
|
||||
if signed:
|
||||
components['signed_off'] = True
|
||||
|
||||
# Join all lines
|
||||
footer = '\n'.join(footer_lines)
|
||||
|
||||
# Validate footer
|
||||
warnings = []
|
||||
|
||||
# Check breaking change format
|
||||
if breaking and not footer.startswith('BREAKING CHANGE:'):
|
||||
warnings.append('BREAKING CHANGE must be uppercase')
|
||||
|
||||
# Check issue reference format
|
||||
for line in footer_lines:
|
||||
if 'closes' in line.lower() and not line.startswith('Closes'):
|
||||
warnings.append('Use "Closes" (capitalized)')
|
||||
if 'fixes' in line.lower() and not line.startswith('Fixes'):
|
||||
warnings.append('Use "Fixes" (capitalized)')
|
||||
if 'refs' in line.lower() and not line.startswith('Refs'):
|
||||
warnings.append('Use "Refs" (capitalized)')
|
||||
|
||||
# Check for proper issue number format
|
||||
if any([closes, fixes, refs]):
|
||||
# Make sure all issue numbers are valid
|
||||
all_issues = parse_issue_numbers(closes) + parse_issue_numbers(fixes) + parse_issue_numbers(refs)
|
||||
if not all_issues:
|
||||
warnings.append('No valid issue numbers found')
|
||||
|
||||
# Build response
|
||||
response = {
|
||||
'footer': footer,
|
||||
'components': components,
|
||||
'line_count': len(footer_lines),
|
||||
'has_breaking': components['breaking_change'],
|
||||
'total_issues': components['closes_issues'] + components['fixes_issues'] + components['refs_issues'],
|
||||
'warnings': warnings,
|
||||
'valid': len(warnings) == 0
|
||||
}
|
||||
|
||||
# Add quality score
|
||||
score = 100
|
||||
if not components['breaking_change'] and breaking:
|
||||
score -= 10
|
||||
if warnings:
|
||||
score -= len(warnings) * 5
|
||||
|
||||
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',
|
||||
'footer': None,
|
||||
'valid': False
|
||||
}))
|
||||
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)}',
|
||||
'footer': None,
|
||||
'valid': False
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
# Build footer
|
||||
result = build_footer(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)}',
|
||||
'footer': None,
|
||||
'valid': False
|
||||
}))
|
||||
sys.exit(2)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user