671 lines
22 KiB
Python
671 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GitHub Issue Closer - Auto-close issues after /auto-implement workflow.
|
|
|
|
Provides functionality to automatically close GitHub issues after successful
|
|
autonomous feature implementation. Uses gh CLI for GitHub operations with
|
|
comprehensive security validation.
|
|
|
|
Security Features:
|
|
- CWE-20: Input validation (positive integers, max 999999)
|
|
- CWE-78: Command injection prevention (subprocess list args, shell=False)
|
|
- CWE-117: Log injection prevention (sanitize newlines, control characters)
|
|
- Audit logging for all gh CLI operations
|
|
|
|
Key Functions:
|
|
1. extract_issue_number() - Extract issue number from command args
|
|
2. validate_issue_state() - Check if issue exists and is open
|
|
3. generate_close_summary() - Format markdown summary for closing
|
|
4. close_github_issue() - Close issue via gh CLI
|
|
5. prompt_user_consent() - Interactive consent prompt
|
|
|
|
Workflow:
|
|
1. Extract issue number from command args (patterns: "issue #8", "#8", "Issue 8")
|
|
2. Prompt user for consent (interactive)
|
|
3. Validate issue exists and is open (validate_issue_state)
|
|
4. Generate close summary (generate_close_summary)
|
|
5. Close issue with summary (close_github_issue)
|
|
6. Graceful degradation on any failure (non-blocking)
|
|
|
|
Usage:
|
|
from github_issue_closer import (
|
|
extract_issue_number,
|
|
validate_issue_state,
|
|
generate_close_summary,
|
|
close_github_issue,
|
|
prompt_user_consent,
|
|
)
|
|
|
|
# Extract issue number
|
|
issue_num = extract_issue_number("implement issue #8")
|
|
# Returns: 8
|
|
|
|
# Prompt for consent
|
|
if not prompt_user_consent(issue_num):
|
|
return # User declined
|
|
|
|
# Validate issue state
|
|
validate_issue_state(issue_num) # Raises if not open
|
|
|
|
# Generate summary
|
|
metadata = {
|
|
'pr_url': 'https://github.com/user/repo/pull/42',
|
|
'commit_hash': 'abc123',
|
|
'files_changed': ['file1.py', 'file2.py'],
|
|
'agents_passed': ['researcher', 'planner', 'test-master',
|
|
'implementer', 'reviewer', 'security-auditor', 'doc-master'],
|
|
}
|
|
summary = generate_close_summary(issue_num, metadata)
|
|
|
|
# Close issue
|
|
close_github_issue(issue_num, summary)
|
|
|
|
Date: 2025-11-18
|
|
Issue: #91 (Auto-close GitHub issues after /auto-implement)
|
|
Agent: implementer
|
|
Phase: TDD Green (making tests pass)
|
|
|
|
Design Patterns:
|
|
See library-design-patterns skill for standardized design patterns.
|
|
See api-integration-patterns skill for standardized design patterns.
|
|
See error-handling-patterns skill for exception hierarchy and error handling best practices.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
from subprocess import CalledProcessError, TimeoutExpired
|
|
|
|
# Import security utilities for audit logging
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from security_utils import audit_log
|
|
|
|
|
|
# =============================================================================
|
|
# EXCEPTIONS
|
|
# =============================================================================
|
|
|
|
|
|
class GitHubAPIError(Exception):
|
|
"""Base exception for GitHub API errors."""
|
|
pass
|
|
|
|
|
|
class IssueNotFoundError(GitHubAPIError):
|
|
"""Exception raised when GitHub issue is not found."""
|
|
pass
|
|
|
|
|
|
class IssueAlreadyClosedError(GitHubAPIError):
|
|
"""Exception raised when GitHub issue is already closed."""
|
|
pass
|
|
|
|
|
|
# =============================================================================
|
|
# CONSTANTS
|
|
# =============================================================================
|
|
|
|
|
|
# Maximum issue number (prevent resource exhaustion)
|
|
MAX_ISSUE_NUMBER = 999999
|
|
|
|
# Subprocess timeout (seconds)
|
|
GH_CLI_TIMEOUT = 10
|
|
|
|
|
|
# =============================================================================
|
|
# CORE FUNCTIONS
|
|
# =============================================================================
|
|
|
|
|
|
def extract_issue_number(feature_request: str) -> Optional[int]:
|
|
"""
|
|
Extract issue number from feature request command args.
|
|
|
|
Recognizes patterns:
|
|
- "issue #8", "#8", "Issue 8" (standard)
|
|
- "GH-42" (GitHub shorthand)
|
|
- "closes #8", "fixes #8", "resolves #8" (conventional commits)
|
|
- Case-insensitive
|
|
- Uses first occurrence if multiple mentions
|
|
|
|
Args:
|
|
feature_request: Command args from /auto-implement
|
|
|
|
Returns:
|
|
Issue number as integer, or None if no issue number found
|
|
|
|
Examples:
|
|
>>> extract_issue_number("implement issue #8")
|
|
8
|
|
>>> extract_issue_number("implement #8 feature")
|
|
8
|
|
>>> extract_issue_number("Issue 8 implementation")
|
|
8
|
|
>>> extract_issue_number("GH-42 implementation")
|
|
42
|
|
>>> extract_issue_number("fixes #123 - login bug")
|
|
123
|
|
>>> extract_issue_number("implement new feature")
|
|
None
|
|
"""
|
|
if not feature_request:
|
|
return None
|
|
|
|
# Patterns ordered by specificity (most specific first)
|
|
# Case-insensitive, captures first occurrence
|
|
patterns = [
|
|
r'(?:closes?|fix(?:es)?|resolves?)\s*#(\d+)', # "closes #8", "fixes #8", "resolves #8"
|
|
r'GH-(\d+)', # "GH-42" (GitHub shorthand)
|
|
r'issue\s*#(\d+)', # "issue #8"
|
|
r'#(\d+)', # "#8" (standalone)
|
|
r'issue\s+(\d+)', # "Issue 8" (no hash)
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, feature_request, re.IGNORECASE)
|
|
if match:
|
|
return int(match.group(1))
|
|
|
|
return None
|
|
|
|
|
|
def validate_issue_state(issue_number: int) -> bool:
|
|
"""
|
|
Validate issue exists and is open via gh CLI.
|
|
|
|
Args:
|
|
issue_number: GitHub issue number
|
|
|
|
Returns:
|
|
True if issue exists and is open
|
|
|
|
Raises:
|
|
ValueError: If issue number is invalid (CWE-20)
|
|
IssueNotFoundError: If issue doesn't exist
|
|
IssueAlreadyClosedError: If issue is already closed
|
|
GitHubAPIError: If gh CLI fails (timeout, network)
|
|
|
|
Security:
|
|
- CWE-20: Validates issue number is positive integer (1-999999)
|
|
- CWE-78: Uses subprocess list args (never shell=True)
|
|
- Audit logging: Logs all gh CLI operations
|
|
|
|
Examples:
|
|
>>> validate_issue_state(8)
|
|
True
|
|
>>> validate_issue_state(-1)
|
|
ValueError: Issue number must be positive
|
|
>>> validate_issue_state(999)
|
|
IssueNotFoundError: Issue #999 not found
|
|
"""
|
|
# CWE-20: Input validation - positive integers only
|
|
if not isinstance(issue_number, int) or issue_number <= 0:
|
|
raise ValueError(f"Issue number must be positive integer (got: {issue_number})")
|
|
|
|
if issue_number > MAX_ISSUE_NUMBER:
|
|
raise ValueError(f"Issue number too large (max: {MAX_ISSUE_NUMBER})")
|
|
|
|
# CWE-78: Command injection prevention - list args, shell=False
|
|
cmd = ['gh', 'issue', 'view', str(issue_number), '--json', 'state,title,number']
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=GH_CLI_TIMEOUT,
|
|
check=True,
|
|
)
|
|
|
|
# Parse JSON response
|
|
data = json.loads(result.stdout)
|
|
|
|
# Check state
|
|
if data['state'] == 'closed':
|
|
audit_log(
|
|
event_type='validate_issue_state',
|
|
status='already_closed',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'title': data.get('title', ''),
|
|
},
|
|
)
|
|
raise IssueAlreadyClosedError(f"Issue #{issue_number} is already closed")
|
|
|
|
# Success
|
|
audit_log(
|
|
event_type='validate_issue_state',
|
|
status='success',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'state': data['state'],
|
|
'title': data.get('title', ''),
|
|
},
|
|
)
|
|
return True
|
|
|
|
except TimeoutExpired as e:
|
|
audit_log(
|
|
event_type='validate_issue_state',
|
|
status='timeout',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'timeout': GH_CLI_TIMEOUT,
|
|
},
|
|
)
|
|
raise GitHubAPIError(f"Timeout validating issue #{issue_number}") from e
|
|
|
|
except CalledProcessError as e:
|
|
# Check if issue not found
|
|
if 'not found' in e.stderr.lower():
|
|
audit_log(
|
|
event_type='validate_issue_state',
|
|
status='not_found',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'stderr': e.stderr,
|
|
},
|
|
)
|
|
raise IssueNotFoundError(f"Issue #{issue_number} not found") from e
|
|
|
|
# Other gh CLI errors
|
|
audit_log(
|
|
event_type='validate_issue_state',
|
|
status='failed',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'stderr': e.stderr,
|
|
},
|
|
)
|
|
raise GitHubAPIError(f"Failed to validate issue #{issue_number}: {e.stderr}") from e
|
|
|
|
|
|
def sanitize_output(text: str) -> str:
|
|
"""
|
|
Sanitize text for log/comment output.
|
|
|
|
Security: CWE-117 - Log injection prevention
|
|
Removes control characters and replaces newlines with spaces.
|
|
|
|
Args:
|
|
text: Text to sanitize
|
|
|
|
Returns:
|
|
Sanitized text with control chars removed
|
|
|
|
Examples:
|
|
>>> sanitize_output("file\\nwith\\nnewlines.py")
|
|
'file with newlines.py'
|
|
>>> sanitize_output("file\\x00control.py")
|
|
'filecontrol.py'
|
|
"""
|
|
# Remove control characters (CWE-117)
|
|
sanitized = ''.join(char if ord(char) >= 32 or char == '\n' else '' for char in text)
|
|
|
|
# Replace single newlines with spaces (preserve paragraph structure)
|
|
sanitized = re.sub(r'(?<!\n)\n(?!\n)', ' ', sanitized)
|
|
|
|
return sanitized
|
|
|
|
|
|
def generate_close_summary(issue_number: int, workflow_metadata: Dict[str, Any]) -> str:
|
|
"""
|
|
Generate markdown summary for closing issue.
|
|
|
|
Args:
|
|
issue_number: GitHub issue number
|
|
workflow_metadata: Workflow metadata from auto_git_workflow hook
|
|
Expected keys:
|
|
- pr_url (optional): Pull request URL
|
|
- commit_hash: Git commit hash
|
|
- files_changed: List of changed file paths
|
|
- agents_passed (optional): List of agent names
|
|
|
|
Returns:
|
|
Markdown-formatted summary string
|
|
|
|
Security:
|
|
- CWE-117: Sanitizes file names and metadata (remove control chars)
|
|
|
|
Examples:
|
|
>>> metadata = {
|
|
... 'pr_url': 'https://github.com/user/repo/pull/42',
|
|
... 'commit_hash': 'abc123',
|
|
... 'files_changed': ['file1.py', 'file2.py'],
|
|
... 'agents_passed': ['researcher', 'planner', 'test-master',
|
|
... 'implementer', 'reviewer', 'security-auditor', 'doc-master'],
|
|
... }
|
|
>>> summary = generate_close_summary(8, metadata)
|
|
>>> 'Completed via /auto-implement' in summary
|
|
True
|
|
>>> 'All 7 agents passed' in summary
|
|
True
|
|
"""
|
|
# Extract metadata
|
|
pr_url = workflow_metadata.get('pr_url')
|
|
commit_hash = workflow_metadata.get('commit_hash', 'N/A')
|
|
files_changed = workflow_metadata.get('files_changed', [])
|
|
agents_passed = workflow_metadata.get('agents_passed', [])
|
|
|
|
# Sanitize commit hash (CWE-117)
|
|
commit_hash = sanitize_output(commit_hash)
|
|
|
|
# Build summary sections (using single newlines to prevent log injection - CWE-117)
|
|
summary_lines = [
|
|
f"## Issue #{issue_number} Completed via /auto-implement",
|
|
"### Workflow Status",
|
|
]
|
|
|
|
# Agent status
|
|
if agents_passed:
|
|
summary_lines.append(f"All {len(agents_passed)} agents passed:")
|
|
for agent in agents_passed:
|
|
summary_lines.append(f"- {agent}")
|
|
else:
|
|
summary_lines.append("Workflow completed successfully")
|
|
|
|
# PR section (if available)
|
|
if pr_url:
|
|
summary_lines.append("### Pull Request")
|
|
summary_lines.append(f"- {pr_url}")
|
|
|
|
# Commit section
|
|
summary_lines.append("### Commit")
|
|
summary_lines.append(f"- {commit_hash}")
|
|
|
|
# Files changed section
|
|
if files_changed:
|
|
summary_lines.append("### Files Changed")
|
|
summary_lines.append(f"{len(files_changed)} files changed:")
|
|
|
|
# Show first 10 files, truncate rest
|
|
display_files = files_changed[:10]
|
|
for file_path in display_files:
|
|
# Sanitize file path (CWE-117)
|
|
safe_path = sanitize_output(str(file_path))
|
|
summary_lines.append(f"- {safe_path}")
|
|
|
|
# Truncation message
|
|
if len(files_changed) > 10:
|
|
remaining = len(files_changed) - 10
|
|
summary_lines.append(f"... {remaining} more")
|
|
|
|
# Footer
|
|
summary_lines.append("---")
|
|
summary_lines.append("Generated by autonomous-dev /auto-implement workflow")
|
|
|
|
return "\n".join(summary_lines)
|
|
|
|
|
|
def close_github_issue(issue_number: int, comment: str) -> bool:
|
|
"""
|
|
Close GitHub issue via gh CLI with comment.
|
|
|
|
Args:
|
|
issue_number: GitHub issue number
|
|
comment: Close comment (markdown formatted)
|
|
|
|
Returns:
|
|
True if issue closed successfully
|
|
|
|
Raises:
|
|
ValueError: If issue number is invalid (CWE-20)
|
|
IssueNotFoundError: If issue doesn't exist
|
|
GitHubAPIError: If gh CLI fails (timeout, network)
|
|
|
|
Security:
|
|
- CWE-20: Validates issue number is positive integer
|
|
- CWE-78: Uses subprocess list args (never shell=True)
|
|
- Audit logging: Logs all gh CLI operations
|
|
|
|
Examples:
|
|
>>> close_github_issue(8, "Completed via /auto-implement")
|
|
True
|
|
>>> close_github_issue(-1, "test")
|
|
ValueError: Issue number must be positive
|
|
"""
|
|
# CWE-20: Input validation - positive integers only
|
|
if not isinstance(issue_number, int) or issue_number <= 0:
|
|
raise ValueError(f"Issue number must be positive integer (got: {issue_number})")
|
|
|
|
if issue_number > MAX_ISSUE_NUMBER:
|
|
raise ValueError(f"Issue number too large (max: {MAX_ISSUE_NUMBER})")
|
|
|
|
# CWE-78: Command injection prevention - list args, shell=False
|
|
cmd = ['gh', 'issue', 'close', str(issue_number), '--comment', comment]
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=GH_CLI_TIMEOUT,
|
|
check=True,
|
|
)
|
|
|
|
# Success
|
|
log_audit_event({
|
|
'action': 'close_github_issue',
|
|
'issue_number': issue_number,
|
|
'status': 'success',
|
|
'timestamp': None, # Will be added by audit_log
|
|
})
|
|
|
|
return True
|
|
|
|
except TimeoutExpired as e:
|
|
audit_log(
|
|
event_type='close_github_issue',
|
|
status='timeout',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'timeout': GH_CLI_TIMEOUT,
|
|
},
|
|
)
|
|
raise GitHubAPIError(f"Timeout closing issue #{issue_number}") from e
|
|
|
|
except CalledProcessError as e:
|
|
# Check if issue not found
|
|
if 'not found' in e.stderr.lower():
|
|
audit_log(
|
|
event_type='close_github_issue',
|
|
status='not_found',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'stderr': e.stderr,
|
|
},
|
|
)
|
|
raise IssueNotFoundError(f"Issue #{issue_number} not found") from e
|
|
|
|
# Check if already closed (idempotent)
|
|
if 'already closed' in e.stderr.lower():
|
|
audit_log(
|
|
event_type='close_github_issue',
|
|
status='already_closed',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'stderr': e.stderr,
|
|
},
|
|
)
|
|
return True # Idempotent - already closed is success
|
|
|
|
# Other gh CLI errors
|
|
audit_log(
|
|
event_type='close_github_issue',
|
|
status='failed',
|
|
context={
|
|
'issue_number': issue_number,
|
|
'stderr': e.stderr,
|
|
},
|
|
)
|
|
raise GitHubAPIError(f"Failed to close issue #{issue_number}: {e.stderr}") from e
|
|
|
|
|
|
def log_audit_event(event: Dict[str, Any]) -> None:
|
|
"""
|
|
Log audit event for issue closing operations.
|
|
|
|
Wrapper around security_utils.audit_log() for consistent formatting.
|
|
|
|
Args:
|
|
event: Event dictionary with keys:
|
|
- action: Operation name (e.g., 'close_github_issue')
|
|
- issue_number: GitHub issue number
|
|
- status: 'success', 'failed', 'timeout', etc.
|
|
- timestamp: Optional timestamp (auto-added if None)
|
|
|
|
Security:
|
|
- Audit logging requirement for all gh CLI operations
|
|
|
|
Examples:
|
|
>>> log_audit_event({
|
|
... 'action': 'close_github_issue',
|
|
... 'issue_number': 8,
|
|
... 'status': 'success',
|
|
... })
|
|
"""
|
|
audit_log(
|
|
event_type=event.get('action', 'github_issue_operation'),
|
|
status=event.get('status', 'unknown'),
|
|
context={
|
|
'issue_number': event.get('issue_number'),
|
|
'action': event.get('action'),
|
|
},
|
|
)
|
|
|
|
|
|
def prompt_user_consent(issue_number: int, title: str = "") -> bool:
|
|
"""
|
|
Prompt user for consent to close issue.
|
|
|
|
Checks environment variable and user preferences first, then prompts
|
|
if needed. Implements first-run consent pattern (same as AUTO_GIT_ENABLED).
|
|
|
|
Priority order:
|
|
1. AUTO_CLOSE_ISSUES environment variable (if set)
|
|
2. Saved user preference (if previously answered)
|
|
3. Interactive first-run prompt (ask once, remember forever)
|
|
|
|
Args:
|
|
issue_number: GitHub issue number
|
|
title: Issue title (optional, for display)
|
|
|
|
Returns:
|
|
True if user consents (env var, saved pref, or interactive yes), False otherwise
|
|
|
|
Environment Variables:
|
|
AUTO_CLOSE_ISSUES: Set to 'true' to auto-close, 'false' to never close
|
|
|
|
Examples:
|
|
>>> # Environment variable set
|
|
>>> os.environ['AUTO_CLOSE_ISSUES'] = 'true'
|
|
>>> prompt_user_consent(8)
|
|
True # No prompt, uses env var
|
|
|
|
>>> # First run (no saved preference)
|
|
>>> prompt_user_consent(8, "Add authentication")
|
|
Auto-close GitHub issues when features complete? [yes/no]: yes
|
|
✓ Preference saved. You won't be asked again.
|
|
True
|
|
|
|
>>> # Subsequent runs (preference saved)
|
|
>>> prompt_user_consent(42)
|
|
True # No prompt, uses saved preference
|
|
"""
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Import UserStateManager
|
|
try:
|
|
from .user_state_manager import UserStateManager, DEFAULT_STATE_FILE
|
|
except ImportError:
|
|
# Direct script execution
|
|
lib_dir = Path(__file__).parent.resolve()
|
|
sys.path.insert(0, str(lib_dir))
|
|
from user_state_manager import UserStateManager, DEFAULT_STATE_FILE
|
|
|
|
# STEP 1: Check environment variable (highest priority)
|
|
env_value = os.environ.get('AUTO_CLOSE_ISSUES', '').strip().lower()
|
|
if env_value in ('true', 'yes', '1'):
|
|
return True
|
|
elif env_value in ('false', 'no', '0'):
|
|
return False
|
|
|
|
# STEP 2: Check saved user preference
|
|
try:
|
|
manager = UserStateManager(DEFAULT_STATE_FILE)
|
|
saved_preference = manager.get_preference('auto_close_issues')
|
|
|
|
if saved_preference is not None:
|
|
# User has answered before, use saved preference
|
|
return bool(saved_preference)
|
|
|
|
except Exception:
|
|
# If user state manager fails, fall back to interactive prompt
|
|
pass
|
|
|
|
# STEP 3: First-run interactive prompt
|
|
print("\n" + "="*60)
|
|
print("GitHub Issue Auto-Close Configuration")
|
|
print("="*60)
|
|
print("\nWhen features complete successfully, automatically close the")
|
|
print("associated GitHub issue?")
|
|
print("\nBenefits:")
|
|
print(" • Fully automated workflow (no manual cleanup)")
|
|
print(" • Unattended batch processing (/batch-implement)")
|
|
print(" • Issue closed with workflow metadata")
|
|
print("\nRequirements:")
|
|
print(" • gh CLI installed and authenticated")
|
|
print(" • Include issue number in request (e.g., 'issue #72')")
|
|
print("\nYou can override later with AUTO_CLOSE_ISSUES environment variable.")
|
|
print("="*60 + "\n")
|
|
|
|
# Retry loop for invalid input
|
|
while True:
|
|
try:
|
|
response = input("Auto-close GitHub issues when features complete? [yes/no]: ").strip().lower()
|
|
|
|
if response in ('yes', 'y'):
|
|
# Save preference
|
|
try:
|
|
manager = UserStateManager(DEFAULT_STATE_FILE)
|
|
manager.set_preference('auto_close_issues', True)
|
|
manager.save()
|
|
print("✓ Preference saved. You won't be asked again.\n")
|
|
except Exception as e:
|
|
print(f"⚠️ Could not save preference: {e}")
|
|
print(" You'll be prompted again next time.\n")
|
|
|
|
return True
|
|
|
|
elif response in ('no', 'n'):
|
|
# Save preference
|
|
try:
|
|
manager = UserStateManager(DEFAULT_STATE_FILE)
|
|
manager.set_preference('auto_close_issues', False)
|
|
manager.save()
|
|
print("✓ Preference saved. You won't be asked again.\n")
|
|
print(" To enable later, set: export AUTO_CLOSE_ISSUES=true\n")
|
|
except Exception as e:
|
|
print(f"⚠️ Could not save preference: {e}")
|
|
print(" You'll be prompted again next time.\n")
|
|
|
|
return False
|
|
|
|
else:
|
|
print("Invalid input. Please enter 'yes' or 'no'.")
|
|
|
|
except EOFError:
|
|
# Handle EOF gracefully (e.g., piped input)
|
|
print("\nEOF encountered - defaulting to 'no'.")
|
|
return False
|
|
except KeyboardInterrupt:
|
|
# Re-raise KeyboardInterrupt - let user cancel completely
|
|
print("\nCancelled by user.")
|
|
raise
|