Files
gh-netresearch-claude-code-…/scripts/validate-commit-message.py
2025-11-30 08:43:24 +08:00

257 lines
8.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""
TYPO3 Commit Message Validator
Validates commit messages against TYPO3 contribution standards
"""
import sys
import re
import argparse
from typing import List, Tuple
VALID_TYPES = ['BUGFIX', 'FEATURE', 'TASK', 'DOCS', 'SECURITY']
BREAKING_PREFIX = '[!!!]'
class CommitMessageValidator:
def __init__(self, message: str):
self.message = message
self.lines = message.split('\n')
self.errors = []
self.warnings = []
def validate(self) -> Tuple[bool, List[str], List[str]]:
"""Run all validation checks"""
self.check_subject_line()
self.check_blank_line()
self.check_footer()
self.check_change_id()
return len(self.errors) == 0, self.errors, self.warnings
def check_subject_line(self):
"""Validate the subject line"""
if not self.lines:
self.errors.append("Commit message is empty")
return
subject = self.lines[0]
# Check for commit type
type_pattern = r'^\[(?:\[!!!\])?(BUGFIX|FEATURE|TASK|DOCS|SECURITY)\]'
match = re.match(type_pattern, subject)
if not match:
self.errors.append(
f"Subject must start with commit type: {', '.join(f'[{t}]' for t in VALID_TYPES)}"
)
return
commit_type = match.group(1)
# Check for breaking change prefix
if subject.startswith('[!!!]'):
if commit_type == 'BUGFIX':
self.warnings.append(
"Breaking changes are unusual for BUGFIX. Consider using FEATURE or TASK"
)
# Extract subject without type prefix
subject_without_type = re.sub(type_pattern, '', subject).strip()
# Check length
if len(subject) > 72:
self.errors.append(
f"Subject line is {len(subject)} characters (max 72). Current: {len(subject)}"
)
elif len(subject) > 52:
self.warnings.append(
f"Subject line is {len(subject)} characters (recommended max 52)"
)
# Check capitalization
if subject_without_type and not subject_without_type[0].isupper():
self.errors.append("Subject description must start with uppercase letter")
# Check for period at end
if subject.endswith('.'):
self.errors.append("Subject line should not end with a period")
# Check for imperative mood (heuristic)
if subject_without_type:
first_word = subject_without_type.split()[0].lower()
if first_word.endswith('ed') or first_word.endswith('ing'):
self.warnings.append(
f"Use imperative mood: '{first_word}' may not be imperative. "
"Use 'Fix' not 'Fixed' or 'Fixing'"
)
def check_blank_line(self):
"""Check for blank line after subject"""
if len(self.lines) < 2:
return # Only subject line, no body
if len(self.lines) >= 2 and self.lines[1] != '':
self.errors.append("Second line must be blank (separate subject from body)")
def check_footer(self):
"""Check footer tags"""
footer_pattern = r'^(Resolves|Related|Releases|Depends|Reverts):\s*'
has_resolves = False
has_releases = False
has_change_id = False
for i, line in enumerate(self.lines):
if re.match(footer_pattern, line):
# Check format: should have colon followed by space
if not re.match(r'^[A-Z][a-z]+:\s+', line):
self.errors.append(
f"Line {i+1}: Footer tag must have colon followed by space: '{line}'"
)
# Check specific tags
if line.startswith('Resolves:'):
has_resolves = True
# Validate issue number format
if not re.match(r'^Resolves:\s+#\d+', line):
self.errors.append(
f"Line {i+1}: Resolves must reference issue number: 'Resolves: #12345'"
)
elif line.startswith('Related:'):
if not re.match(r'^Related:\s+#\d+', line):
self.errors.append(
f"Line {i+1}: Related must reference issue number: 'Related: #12345'"
)
elif line.startswith('Releases:'):
has_releases = True
# Validate releases format
releases_value = line.split(':', 1)[1].strip()
releases = [r.strip() for r in releases_value.split(',')]
for release in releases:
if release != 'main' and not re.match(r'^\d+\.\d+$', release):
self.errors.append(
f"Line {i+1}: Invalid release format '{release}'. "
"Use 'main' or version like '13.4'"
)
elif line.startswith('Change-Id:'):
has_change_id = True
# Warnings for missing tags
if not has_resolves:
self.warnings.append(
"No 'Resolves: #<issue>' tag found. Required for features and tasks."
)
if not has_releases:
self.warnings.append(
"No 'Releases:' tag found. Required to specify target versions."
)
def check_change_id(self):
"""Check for Change-Id"""
change_id_pattern = r'^Change-Id:\s+I[a-f0-9]{40}$'
has_change_id = any(re.match(change_id_pattern, line) for line in self.lines)
if not has_change_id:
self.warnings.append(
"No Change-Id found. It will be added automatically by git commit-msg hook."
)
def check_line_length(self):
"""Check body line lengths"""
for i, line in enumerate(self.lines[2:], start=3): # Skip subject and blank line
if line.startswith(('Resolves:', 'Related:', 'Releases:', 'Change-Id:', 'Depends:', 'Reverts:')):
continue # Skip footer
if len(line) > 72:
# Allow URLs to be longer
if not re.search(r'https?://', line):
self.warnings.append(
f"Line {i}: Length {len(line)} exceeds 72 characters"
)
def main():
parser = argparse.ArgumentParser(
description='Validate TYPO3 commit messages',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('--file', '-f', help='File containing commit message')
parser.add_argument('--message', '-m', help='Commit message string')
parser.add_argument('--strict', action='store_true',
help='Treat warnings as errors')
args = parser.parse_args()
# Get message
if args.file:
try:
with open(args.file, 'r') as f:
message = f.read()
except FileNotFoundError:
print(f"Error: File not found: {args.file}")
return 1
elif args.message:
message = args.message
else:
# Read from last commit
try:
import subprocess
result = subprocess.run(
['git', 'log', '-1', '--pretty=%B'],
capture_output=True,
text=True,
check=True
)
message = result.stdout
except subprocess.CalledProcessError:
print("Error: Could not read last commit message")
print("Usage: Provide --file or --message, or run in a git repository")
return 1
# Validate
validator = CommitMessageValidator(message)
is_valid, errors, warnings = validator.validate()
# Print results
print("=" * 60)
print("TYPO3 Commit Message Validation")
print("=" * 60)
print()
if errors:
print("❌ ERRORS:")
for error in errors:
print(f"{error}")
print()
if warnings:
print("⚠️ WARNINGS:")
for warning in warnings:
print(f"{warning}")
print()
if not errors and not warnings:
print("✅ Commit message is valid!")
elif not errors:
print("✅ No errors found (warnings can be ignored)")
else:
print("❌ Validation failed. Please fix errors above.")
print("=" * 60)
# Exit code
if errors or (args.strict and warnings):
return 1
return 0
if __name__ == '__main__':
sys.exit(main())