Initial commit
This commit is contained in:
304
commands/message-generation/.scripts/body-composer.sh
Executable file
304
commands/message-generation/.scripts/body-composer.sh
Executable file
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env bash
|
||||
# Script: body-composer.sh
|
||||
# Purpose: Compose commit message body with proper formatting and wrapping
|
||||
# Author: Git Commit Assistant Plugin
|
||||
# Version: 1.0.0
|
||||
#
|
||||
# Usage:
|
||||
# export CHANGES="change1,change2,change3"
|
||||
# export WRAP_LENGTH=72
|
||||
# export FORMAT=bullets
|
||||
# ./body-composer.sh
|
||||
#
|
||||
# Environment Variables:
|
||||
# CHANGES - Comma-separated list of changes or file paths
|
||||
# WRAP_LENGTH - Line wrap length (default: 72)
|
||||
# FORMAT - Output format: bullets or paragraphs (default: bullets)
|
||||
# WHY_CONTEXT - Optional context about why changes were made
|
||||
#
|
||||
# Returns:
|
||||
# Formatted body text to stdout
|
||||
# JSON summary to stderr (optional)
|
||||
#
|
||||
# Exit Codes:
|
||||
# 0 - Success
|
||||
# 1 - Invalid input
|
||||
# 2 - Processing error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Default values
|
||||
WRAP_LENGTH="${WRAP_LENGTH:-72}"
|
||||
FORMAT="${FORMAT:-bullets}"
|
||||
WHY_CONTEXT="${WHY_CONTEXT:-}"
|
||||
|
||||
# Validate CHANGES is provided
|
||||
if [ -z "${CHANGES:-}" ]; then
|
||||
echo "ERROR: CHANGES environment variable is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate FORMAT
|
||||
if [ "$FORMAT" != "bullets" ] && [ "$FORMAT" != "paragraphs" ]; then
|
||||
echo "ERROR: FORMAT must be 'bullets' or 'paragraphs'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate WRAP_LENGTH
|
||||
if ! [[ "$WRAP_LENGTH" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: WRAP_LENGTH must be a positive integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to convert to imperative mood
|
||||
convert_to_imperative() {
|
||||
local text="$1"
|
||||
|
||||
# Common past tense -> imperative
|
||||
text=$(echo "$text" | sed -E 's/\b(added|adds)\b/add/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(fixed|fixes)\b/fix/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(updated|updates)\b/update/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(removed|removes)\b/remove/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(changed|changes)\b/change/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(improved|improves)\b/improve/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(refactored|refactors)\b/refactor/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(implemented|implements)\b/implement/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(created|creates)\b/create/gi')
|
||||
text=$(echo "$text" | sed -E 's/\b(deleted|deletes)\b/delete/gi')
|
||||
|
||||
# Lowercase first letter
|
||||
text="$(echo "${text:0:1}" | tr '[:upper:]' '[:lower:]')${text:1}"
|
||||
|
||||
echo "$text"
|
||||
}
|
||||
|
||||
# Function to wrap text at specified length
|
||||
wrap_text() {
|
||||
local text="$1"
|
||||
local width="$2"
|
||||
|
||||
echo "$text" | fold -s -w "$width"
|
||||
}
|
||||
|
||||
# Function to format file path as readable change
|
||||
format_file_path() {
|
||||
local filepath="$1"
|
||||
|
||||
# Extract filename and directory
|
||||
local filename=$(basename "$filepath")
|
||||
local dirname=$(dirname "$filepath")
|
||||
|
||||
# Remove extension
|
||||
local name_no_ext="${filename%.*}"
|
||||
|
||||
# Convert to readable format
|
||||
# Example: src/auth/oauth.js -> "add OAuth authentication module"
|
||||
# Example: tests/unit/user.test.js -> "add user unit tests"
|
||||
|
||||
if [[ "$filepath" == *"/test/"* ]] || [[ "$filepath" == *"/tests/"* ]] || [[ "$filename" == *".test."* ]] || [[ "$filename" == *".spec."* ]]; then
|
||||
echo "add ${name_no_ext} tests"
|
||||
elif [[ "$dirname" == "." ]]; then
|
||||
echo "update ${name_no_ext}"
|
||||
else
|
||||
# Extract meaningful part from path
|
||||
local component=$(echo "$dirname" | sed 's|.*/||')
|
||||
echo "update ${component} ${name_no_ext}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to generate bullet points
|
||||
generate_bullets() {
|
||||
local changes_list="$1"
|
||||
|
||||
# Split by comma
|
||||
IFS=',' read -ra items <<< "$changes_list"
|
||||
|
||||
local body=""
|
||||
local bullet_count=0
|
||||
|
||||
for item in "${items[@]}"; do
|
||||
# Trim whitespace
|
||||
item=$(echo "$item" | xargs)
|
||||
|
||||
if [ -z "$item" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if it's a file path
|
||||
if [[ "$item" == *"/"* ]] || [[ "$item" == *"."* ]]; then
|
||||
# Format as file path
|
||||
item=$(format_file_path "$item")
|
||||
fi
|
||||
|
||||
# Convert to imperative mood
|
||||
item=$(convert_to_imperative "$item")
|
||||
|
||||
# Ensure first letter is capitalized for bullet
|
||||
item="$(echo "${item:0:1}" | tr '[:lower:]' '[:upper:]')${item:1}"
|
||||
|
||||
# Wrap if needed (account for "- " prefix)
|
||||
local max_width=$((WRAP_LENGTH - 2))
|
||||
local wrapped=$(wrap_text "$item" "$max_width")
|
||||
|
||||
# Add bullet point
|
||||
echo "$wrapped" | while IFS= read -r line; do
|
||||
if [ "$bullet_count" -eq 0 ] || [ -z "$line" ]; then
|
||||
body="${body}- ${line}\n"
|
||||
else
|
||||
body="${body} ${line}\n" # Indent continuation lines
|
||||
fi
|
||||
done
|
||||
|
||||
bullet_count=$((bullet_count + 1))
|
||||
done
|
||||
|
||||
# Output body (remove trailing newline)
|
||||
echo -ne "$body"
|
||||
}
|
||||
|
||||
# Function to generate paragraphs
|
||||
generate_paragraphs() {
|
||||
local changes_list="$1"
|
||||
|
||||
# Split by comma and join into sentences
|
||||
IFS=',' read -ra items <<< "$changes_list"
|
||||
|
||||
local body=""
|
||||
|
||||
for item in "${items[@]}"; do
|
||||
# Trim whitespace
|
||||
item=$(echo "$item" | xargs)
|
||||
|
||||
if [ -z "$item" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if it's a file path
|
||||
if [[ "$item" == *"/"* ]] || [[ "$item" == *"."* ]]; then
|
||||
item=$(format_file_path "$item")
|
||||
fi
|
||||
|
||||
# Convert to imperative mood
|
||||
item=$(convert_to_imperative "$item")
|
||||
|
||||
# Ensure first letter is capitalized
|
||||
item="$(echo "${item:0:1}" | tr '[:lower:]' '[:upper:]')${item:1}"
|
||||
|
||||
# Add to body
|
||||
if [ -z "$body" ]; then
|
||||
body="$item"
|
||||
else
|
||||
body="${body}. ${item}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Add period at end if not present
|
||||
if [[ ! "$body" =~ \.$ ]]; then
|
||||
body="${body}."
|
||||
fi
|
||||
|
||||
# Wrap text
|
||||
wrapped=$(wrap_text "$body" "$WRAP_LENGTH")
|
||||
|
||||
echo "$wrapped"
|
||||
}
|
||||
|
||||
# Function to add context (why)
|
||||
add_context() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
|
||||
if [ -z "$context" ]; then
|
||||
echo "$body"
|
||||
return
|
||||
fi
|
||||
|
||||
# Ensure first letter is capitalized
|
||||
context="$(echo "${context:0:1}" | tr '[:lower:]' '[:upper:]')${context:1}"
|
||||
|
||||
# Add period if not present
|
||||
if [[ ! "$context" =~ \.$ ]]; then
|
||||
context="${context}."
|
||||
fi
|
||||
|
||||
# Wrap context
|
||||
wrapped_context=$(wrap_text "$context" "$WRAP_LENGTH")
|
||||
|
||||
# Combine with blank line
|
||||
echo -e "${body}\n\n${wrapped_context}"
|
||||
}
|
||||
|
||||
# Main composition logic
|
||||
compose_body() {
|
||||
local body=""
|
||||
|
||||
# Generate based on format
|
||||
if [ "$FORMAT" = "bullets" ]; then
|
||||
body=$(generate_bullets "$CHANGES")
|
||||
else
|
||||
body=$(generate_paragraphs "$CHANGES")
|
||||
fi
|
||||
|
||||
# Add context if provided
|
||||
if [ -n "$WHY_CONTEXT" ]; then
|
||||
body=$(add_context "$body" "$WHY_CONTEXT")
|
||||
fi
|
||||
|
||||
echo "$body"
|
||||
}
|
||||
|
||||
# Validate body
|
||||
validate_body() {
|
||||
local body="$1"
|
||||
|
||||
local line_count=$(echo "$body" | wc -l)
|
||||
local longest_line=$(echo "$body" | awk '{ print length }' | sort -rn | head -1)
|
||||
local bullet_count=$(echo "$body" | grep -c '^- ' || true)
|
||||
|
||||
local warnings=()
|
||||
|
||||
# Check longest line
|
||||
if [ "$longest_line" -gt "$WRAP_LENGTH" ]; then
|
||||
warnings+=("Line exceeds $WRAP_LENGTH characters ($longest_line chars)")
|
||||
fi
|
||||
|
||||
# Check for empty lines at start
|
||||
if echo "$body" | head -1 | grep -q '^$'; then
|
||||
warnings+=("Body starts with empty line")
|
||||
fi
|
||||
|
||||
# Output validation summary to stderr
|
||||
{
|
||||
echo "{"
|
||||
echo " \"line_count\": $line_count,"
|
||||
echo " \"longest_line\": $longest_line,"
|
||||
echo " \"wrap_length\": $WRAP_LENGTH,"
|
||||
echo " \"bullet_count\": $bullet_count,"
|
||||
echo " \"format\": \"$FORMAT\","
|
||||
echo " \"has_context\": $([ -n "$WHY_CONTEXT" ] && echo "true" || echo "false"),"
|
||||
echo " \"warnings\": ["
|
||||
for i in "${!warnings[@]}"; do
|
||||
echo " \"${warnings[$i]}\"$([ $i -lt $((${#warnings[@]} - 1)) ] && echo "," || echo "")"
|
||||
done
|
||||
echo " ],"
|
||||
echo " \"valid\": $([ ${#warnings[@]} -eq 0 ] && echo "true" || echo "false")"
|
||||
echo "}"
|
||||
} >&2
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
# Compose body
|
||||
body=$(compose_body)
|
||||
|
||||
# Validate
|
||||
validate_body "$body"
|
||||
|
||||
# Output body to stdout
|
||||
echo "$body"
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Run main
|
||||
main
|
||||
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()
|
||||
335
commands/message-generation/.scripts/message-validator.sh
Executable file
335
commands/message-generation/.scripts/message-validator.sh
Executable file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env bash
|
||||
# Script: message-validator.sh
|
||||
# Purpose: Validate commit message against conventional commits standard
|
||||
# Author: Git Commit Assistant Plugin
|
||||
# Version: 1.0.0
|
||||
#
|
||||
# Usage:
|
||||
# export MESSAGE="feat: add feature"
|
||||
# export STRICT_MODE=false
|
||||
# export MAX_SUBJECT=50
|
||||
# export MAX_LINE=72
|
||||
# ./message-validator.sh
|
||||
#
|
||||
# Environment Variables:
|
||||
# MESSAGE - Commit message to validate (required)
|
||||
# STRICT_MODE - Enable strict validation (default: false)
|
||||
# MAX_SUBJECT - Maximum subject length (default: 50)
|
||||
# MAX_LINE - Maximum body line length (default: 72)
|
||||
#
|
||||
# Returns:
|
||||
# Validation report to stdout
|
||||
# Exit code indicates validation status
|
||||
#
|
||||
# Exit Codes:
|
||||
# 0 - Valid message
|
||||
# 1 - Invalid message (warnings in normal mode, any issue in strict mode)
|
||||
# 2 - Error (missing input, malformed message)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Default values
|
||||
STRICT_MODE="${STRICT_MODE:-false}"
|
||||
MAX_SUBJECT="${MAX_SUBJECT:-50}"
|
||||
MAX_LINE="${MAX_LINE:-72}"
|
||||
|
||||
# Validation counters
|
||||
declare -a ERRORS=()
|
||||
declare -a WARNINGS=()
|
||||
declare -a SUGGESTIONS=()
|
||||
|
||||
# Score tracking
|
||||
SCORE=100
|
||||
|
||||
# Validate MESSAGE is provided
|
||||
if [ -z "${MESSAGE:-}" ]; then
|
||||
echo "ERROR: MESSAGE environment variable is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Valid commit types
|
||||
VALID_TYPES="feat fix docs style refactor perf test build ci chore revert"
|
||||
|
||||
# Function to check commit type
|
||||
validate_type() {
|
||||
local subject="$1"
|
||||
|
||||
# Extract type (before colon or parenthesis)
|
||||
local type=$(echo "$subject" | grep -oP '^[a-z]+' || echo "")
|
||||
|
||||
if [ -z "$type" ]; then
|
||||
ERRORS+=("No commit type found")
|
||||
SCORE=$((SCORE - 20))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if type is valid
|
||||
if ! echo "$VALID_TYPES" | grep -qw "$type"; then
|
||||
ERRORS+=("Invalid commit type: '$type'")
|
||||
ERRORS+=("Valid types: $VALID_TYPES")
|
||||
SCORE=$((SCORE - 20))
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to check scope format
|
||||
validate_scope() {
|
||||
local subject="$1"
|
||||
|
||||
# Check if scope exists
|
||||
if echo "$subject" | grep -qP '^\w+\([^)]+\):'; then
|
||||
local scope=$(echo "$subject" | grep -oP '^\w+\(\K[^)]+')
|
||||
|
||||
# Scope should be lowercase alphanumeric with hyphens
|
||||
if ! echo "$scope" | grep -qP '^[a-z0-9-]+$'; then
|
||||
WARNINGS+=("Scope should be lowercase alphanumeric with hyphens: '$scope'")
|
||||
SCORE=$((SCORE - 5))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to validate subject line
|
||||
validate_subject() {
|
||||
local subject="$1"
|
||||
|
||||
# Check format: type(scope): description or type: description
|
||||
if ! echo "$subject" | grep -qP '^[a-z]+(\([a-z0-9-]+\))?: .+'; then
|
||||
ERRORS+=("Subject does not match conventional commits format")
|
||||
ERRORS+=("Expected: <type>(<scope>): <description> or <type>: <description>")
|
||||
SCORE=$((SCORE - 30))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate type
|
||||
validate_type "$subject"
|
||||
|
||||
# Validate scope if present
|
||||
validate_scope "$subject"
|
||||
|
||||
# Extract description (after ": ")
|
||||
local description=$(echo "$subject" | sed 's/^[^:]*: //')
|
||||
|
||||
# Check length
|
||||
local length=${#subject}
|
||||
if [ "$length" -gt "$MAX_SUBJECT" ]; then
|
||||
if [ "$length" -gt 72 ]; then
|
||||
ERRORS+=("Subject exceeds hard limit of 72 characters ($length chars)")
|
||||
SCORE=$((SCORE - 30))
|
||||
else
|
||||
WARNINGS+=("Subject exceeds recommended $MAX_SUBJECT characters ($length chars)")
|
||||
SUGGESTIONS+=("Consider shortening subject or moving details to body")
|
||||
SCORE=$((SCORE - 10))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for capital letter after colon
|
||||
if echo "$description" | grep -qP '^[A-Z]'; then
|
||||
WARNINGS+=("Description should not start with capital letter")
|
||||
SUGGESTIONS+=("Use lowercase after colon: '$(echo "${description:0:1}" | tr '[:upper:]' '[:lower:]')${description:1}'")
|
||||
SCORE=$((SCORE - 5))
|
||||
fi
|
||||
|
||||
# Check for period at end
|
||||
if [[ "$description" =~ \.$ ]]; then
|
||||
WARNINGS+=("Subject should not end with period")
|
||||
SUGGESTIONS+=("Remove period at end")
|
||||
SCORE=$((SCORE - 3))
|
||||
fi
|
||||
|
||||
# Check for imperative mood (simple heuristics)
|
||||
if echo "$description" | grep -qP '\b(added|fixed|updated|removed|changed|improved|created|deleted)\b'; then
|
||||
WARNINGS+=("Use imperative mood (add, fix, update) not past tense")
|
||||
SCORE=$((SCORE - 5))
|
||||
fi
|
||||
|
||||
if echo "$description" | grep -qP '\b(adds|fixes|updates|removes|changes|improves|creates|deletes)\b'; then
|
||||
WARNINGS+=("Use imperative mood (add, fix, update) not present tense")
|
||||
SCORE=$((SCORE - 5))
|
||||
fi
|
||||
|
||||
# Check for vague descriptions
|
||||
if echo "$description" | grep -qiP '\b(update|change|fix|improve)\s+(code|file|stuff|thing)\b'; then
|
||||
SUGGESTIONS+=("Be more specific in description")
|
||||
SCORE=$((SCORE - 5))
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to validate body
|
||||
validate_body() {
|
||||
local body="$1"
|
||||
|
||||
if [ -z "$body" ]; then
|
||||
return 0 # Body is optional
|
||||
fi
|
||||
|
||||
# Check line lengths
|
||||
while IFS= read -r line; do
|
||||
local length=${#line}
|
||||
if [ "$length" -gt "$MAX_LINE" ]; then
|
||||
WARNINGS+=("Body line exceeds $MAX_LINE characters ($length chars)")
|
||||
SCORE=$((SCORE - 3))
|
||||
fi
|
||||
done <<< "$body"
|
||||
|
||||
# Check for imperative mood in body
|
||||
if echo "$body" | grep -qP '\b(added|fixed|updated|removed|changed|improved|created|deleted)\b'; then
|
||||
SUGGESTIONS+=("Consider using imperative mood in body")
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to validate footer
|
||||
validate_footer() {
|
||||
local footer="$1"
|
||||
|
||||
if [ -z "$footer" ]; then
|
||||
return 0 # Footer is optional
|
||||
fi
|
||||
|
||||
# Check for BREAKING CHANGE format
|
||||
if echo "$footer" | grep -qi "breaking change"; then
|
||||
if ! echo "$footer" | grep -q "^BREAKING CHANGE:"; then
|
||||
ERRORS+=("Use 'BREAKING CHANGE:' (uppercase, singular) not 'breaking change'")
|
||||
SCORE=$((SCORE - 15))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for issue references
|
||||
if echo "$footer" | grep -qiP '\b(close|fix|resolve)[sd]?\b'; then
|
||||
# Check format
|
||||
if ! echo "$footer" | grep -qP '^(Closes|Fixes|Resolves|Refs) #[0-9]'; then
|
||||
WARNINGS+=("Issue references should use proper format: 'Closes #123'")
|
||||
SUGGESTIONS+=("Capitalize keyword and use # prefix for issue numbers")
|
||||
SCORE=$((SCORE - 5))
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to check overall structure
|
||||
validate_structure() {
|
||||
local message="$1"
|
||||
|
||||
# Count lines
|
||||
local line_count=$(echo "$message" | wc -l)
|
||||
|
||||
# Split message into parts
|
||||
local subject=$(echo "$message" | head -1)
|
||||
local rest=$(echo "$message" | tail -n +2)
|
||||
|
||||
# Validate subject
|
||||
validate_subject "$subject"
|
||||
|
||||
# If multi-line, check for blank line after subject
|
||||
if [ "$line_count" -gt 1 ]; then
|
||||
local second_line=$(echo "$message" | sed -n '2p')
|
||||
|
||||
if [ -n "$second_line" ]; then
|
||||
ERRORS+=("Blank line required between subject and body")
|
||||
SCORE=$((SCORE - 10))
|
||||
fi
|
||||
|
||||
# Extract body (after blank line, before footer)
|
||||
local body=""
|
||||
local footer=""
|
||||
local in_footer=false
|
||||
|
||||
while IFS= read -r line; do
|
||||
# Check if line is footer token
|
||||
if echo "$line" | grep -qP '^(BREAKING CHANGE:|Closes|Fixes|Resolves|Refs|Reviewed-by|Signed-off-by)'; then
|
||||
in_footer=true
|
||||
fi
|
||||
|
||||
if [ "$in_footer" = true ]; then
|
||||
footer="${footer}${line}\n"
|
||||
else
|
||||
body="${body}${line}\n"
|
||||
fi
|
||||
done <<< "$rest"
|
||||
|
||||
# Remove leading blank line from body
|
||||
body=$(echo -e "$body" | sed '1{/^$/d;}')
|
||||
|
||||
# Validate body and footer
|
||||
validate_body "$body"
|
||||
validate_footer "$footer"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main validation logic
|
||||
main() {
|
||||
echo "COMMIT MESSAGE VALIDATION"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
echo "MESSAGE:"
|
||||
echo "───────────────────────────────────────────────"
|
||||
echo "$MESSAGE"
|
||||
echo ""
|
||||
|
||||
# Perform validation
|
||||
validate_structure "$MESSAGE"
|
||||
|
||||
# Calculate final status
|
||||
local status="VALID"
|
||||
if [ "${#ERRORS[@]}" -gt 0 ]; then
|
||||
status="INVALID"
|
||||
elif [ "$STRICT_MODE" = "true" ] && [ "${#WARNINGS[@]}" -gt 0 ]; then
|
||||
status="INVALID"
|
||||
fi
|
||||
|
||||
# Display results
|
||||
echo "VALIDATION RESULTS:"
|
||||
echo "───────────────────────────────────────────────"
|
||||
|
||||
if [ "${#ERRORS[@]}" -gt 0 ]; then
|
||||
echo "✗ ERRORS:"
|
||||
for error in "${ERRORS[@]}"; do
|
||||
echo " - $error"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "${#WARNINGS[@]}" -gt 0 ]; then
|
||||
echo "⚠ WARNINGS:"
|
||||
for warning in "${WARNINGS[@]}"; do
|
||||
echo " - $warning"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "${#SUGGESTIONS[@]}" -gt 0 ]; then
|
||||
echo "💡 SUGGESTIONS:"
|
||||
for suggestion in "${SUGGESTIONS[@]}"; do
|
||||
echo " - $suggestion"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "${#ERRORS[@]}" -eq 0 ] && [ "${#WARNINGS[@]}" -eq 0 ]; then
|
||||
echo "✓ All checks passed"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "STATUS: $status"
|
||||
echo "SCORE: $SCORE/100"
|
||||
echo "STRICT MODE: $STRICT_MODE"
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════"
|
||||
|
||||
# Exit based on status
|
||||
if [ "$status" = "INVALID" ]; then
|
||||
exit 1
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main
|
||||
main
|
||||
354
commands/message-generation/.scripts/subject-generator.py
Executable file
354
commands/message-generation/.scripts/subject-generator.py
Executable file
@@ -0,0 +1,354 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user