293 lines
8.1 KiB
Python
Executable File
293 lines
8.1 KiB
Python
Executable File
#!/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()
|