465 lines
15 KiB
Python
465 lines
15 KiB
Python
"""
|
|
PR automation library for autonomous-dev plugin.
|
|
|
|
Provides functions to:
|
|
- Validate GitHub CLI prerequisites (installed, authenticated)
|
|
- Get current git branch
|
|
- Parse commit messages for issue references
|
|
- Create GitHub pull requests using gh CLI
|
|
|
|
Dependencies:
|
|
- gh CLI (GitHub CLI) - https://cli.github.com/
|
|
- git command-line tool
|
|
|
|
Author: autonomous-dev
|
|
Date: 2025-10-23
|
|
Workflow: 20251023_104242
|
|
"""
|
|
|
|
import re
|
|
import subprocess
|
|
from typing import Dict, Any, List, Tuple, Optional
|
|
|
|
|
|
def validate_gh_prerequisites() -> Tuple[bool, str]:
|
|
"""
|
|
Validate that GitHub CLI is installed and authenticated.
|
|
|
|
Checks:
|
|
1. gh CLI is installed (gh --version succeeds)
|
|
2. gh CLI is authenticated (gh auth status succeeds)
|
|
|
|
Returns:
|
|
Tuple of (valid, error_message):
|
|
- valid: True if all prerequisites met, False otherwise
|
|
- error_message: Empty string if valid, error description if not
|
|
|
|
Example:
|
|
>>> valid, error = validate_gh_prerequisites()
|
|
>>> if not valid:
|
|
... print(f"Error: {error}")
|
|
"""
|
|
try:
|
|
# Check if gh CLI is installed
|
|
subprocess.run(
|
|
['gh', '--version'],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
except FileNotFoundError:
|
|
return (False, 'GitHub CLI not installed. Install from https://cli.github.com/')
|
|
except subprocess.CalledProcessError:
|
|
return (False, 'GitHub CLI not installed or not working properly')
|
|
except subprocess.TimeoutExpired:
|
|
return (False, 'GitHub CLI command timed out')
|
|
|
|
try:
|
|
# Check if gh CLI is authenticated
|
|
result = subprocess.run(
|
|
['gh', 'auth', 'status'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
# gh auth status returns non-zero exit code when not authenticated
|
|
if result.returncode != 0:
|
|
return (False, 'GitHub CLI not authenticated. Run: gh auth login')
|
|
except subprocess.CalledProcessError as e:
|
|
return (False, 'GitHub CLI not authenticated')
|
|
except subprocess.TimeoutExpired:
|
|
return (False, 'GitHub CLI authentication check timed out')
|
|
|
|
return (True, '')
|
|
|
|
|
|
def get_current_branch() -> str:
|
|
"""
|
|
Get the name of the current git branch.
|
|
|
|
Uses 'git branch' command and parses output to find the current branch
|
|
(marked with * prefix).
|
|
|
|
Returns:
|
|
String name of current branch (e.g., 'feature/pr-automation')
|
|
|
|
Raises:
|
|
subprocess.CalledProcessError: If not in a git repository or git command fails
|
|
|
|
Example:
|
|
>>> branch = get_current_branch()
|
|
>>> print(f"Current branch: {branch}")
|
|
Current branch: feature/pr-automation
|
|
"""
|
|
result = subprocess.run(
|
|
['git', 'branch'],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
|
|
# Parse git branch output to find current branch (marked with *)
|
|
for line in result.stdout.split('\n'):
|
|
if line.startswith('*'):
|
|
# Extract branch name after '* '
|
|
branch = line[2:].strip()
|
|
|
|
# Handle detached HEAD state
|
|
if branch.startswith('(HEAD detached'):
|
|
return 'HEAD'
|
|
|
|
return branch
|
|
|
|
# Fallback if no branch found (shouldn't happen in valid git repo)
|
|
raise RuntimeError('Could not determine current branch')
|
|
|
|
|
|
def extract_issue_numbers(messages: List[str]) -> List[int]:
|
|
"""
|
|
Extract GitHub issue numbers from a list of commit messages.
|
|
|
|
Searches for keywords: Closes, Close, Fixes, Fix, Resolves, Resolve
|
|
Followed by issue numbers like #42, #123, etc.
|
|
|
|
Includes robust error handling for:
|
|
- Non-numeric issue numbers (e.g., #abc)
|
|
- Float-like numbers (e.g., #42.5)
|
|
- Very large numbers
|
|
- Negative numbers (filtered out)
|
|
- Empty references (e.g., just #)
|
|
|
|
Args:
|
|
messages: List of commit message strings to parse
|
|
|
|
Returns:
|
|
List of unique valid issue numbers found, sorted ascending
|
|
Only returns positive integers
|
|
|
|
Example:
|
|
>>> messages = ["Fix #42", "Closes #abc", "Resolve #12.5"]
|
|
>>> extract_issue_numbers(messages)
|
|
[42]
|
|
"""
|
|
# Regex pattern to match issue references
|
|
# Matches: Closes #42, fixes #123, RESOLVES #456, etc.
|
|
# Case-insensitive, supports singular and plural forms
|
|
pattern = r'\b(?:close|closes|fix|fixes|resolve|resolves)\s+#(\d+)\b'
|
|
|
|
issue_numbers = set()
|
|
|
|
for message in messages:
|
|
if not isinstance(message, str):
|
|
continue
|
|
|
|
matches = re.finditer(pattern, message, re.IGNORECASE)
|
|
|
|
for match in matches:
|
|
try:
|
|
# Extract the number part
|
|
number_str = match.group(1)
|
|
|
|
# Convert to int with error handling
|
|
# This handles cases like "42", but rejects "42.5", "abc", etc.
|
|
issue_num = int(number_str)
|
|
|
|
# Filter out invalid issue numbers
|
|
# GitHub issue numbers are positive and typically < 1M
|
|
if issue_num > 0 and issue_num <= 999999:
|
|
issue_numbers.add(issue_num)
|
|
|
|
except (ValueError, OverflowError):
|
|
# Skip invalid issue numbers (non-numeric, too large, etc.)
|
|
# This handles edge cases gracefully without crashing
|
|
continue
|
|
|
|
# Return sorted list of valid issue numbers
|
|
return sorted(list(issue_numbers))
|
|
|
|
|
|
def parse_commit_messages_for_issues(base: str = 'main', head: Optional[str] = None) -> List[int]:
|
|
"""
|
|
Parse commit messages for GitHub issue references with robust error handling.
|
|
|
|
Searches for keywords: Closes, Close, Fixes, Fix, Resolves, Resolve
|
|
Followed by issue numbers like #42, #123, etc.
|
|
|
|
Security Features (GitHub Issue #45 - v3.2.3):
|
|
- Robust issue number extraction via extract_issue_numbers()
|
|
- Handles malformed issue references gracefully (#abc, #42.5, #-1)
|
|
- Filters to valid GitHub issue range (1-999999)
|
|
- No crashes on invalid input (ValueError/OverflowError caught)
|
|
|
|
Args:
|
|
base: Base branch to compare against (default: 'main')
|
|
head: Head branch to compare (default: current branch)
|
|
|
|
Returns:
|
|
List of unique issue numbers found in commit messages, sorted ascending
|
|
Only returns valid positive integers in GitHub issue range
|
|
|
|
Example:
|
|
>>> issues = parse_commit_messages_for_issues(base='main')
|
|
>>> print(f"Found issues: {issues}")
|
|
Found issues: [42, 123, 456]
|
|
|
|
See extract_issue_numbers() for detailed parsing logic and error handling.
|
|
"""
|
|
# Get commit messages between base and head
|
|
if head is None:
|
|
head = 'HEAD'
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'log', f'{base}..{head}', '--pretty=format:%B'],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
# If git log fails (e.g., no commits) or times out, return empty list
|
|
return []
|
|
|
|
commit_text = result.stdout
|
|
|
|
# Use extract_issue_numbers with robust error handling
|
|
return extract_issue_numbers([commit_text])
|
|
|
|
|
|
def create_pull_request(
|
|
title: Optional[str] = None,
|
|
body: Optional[str] = None,
|
|
draft: bool = True,
|
|
base: str = 'main',
|
|
head: Optional[str] = None,
|
|
reviewer: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a GitHub pull request using gh CLI.
|
|
|
|
Args:
|
|
title: Optional PR title (if None, uses --fill from commits)
|
|
body: Optional PR body (if None, uses --fill-verbose from commits)
|
|
draft: Create as draft PR (default True for autonomous workflow)
|
|
base: Target branch (default 'main')
|
|
head: Source branch (default current branch from git)
|
|
reviewer: Optional GitHub handle(s) for reviewer assignment (comma-separated)
|
|
|
|
Returns:
|
|
Dictionary with:
|
|
- success: Boolean indicating if PR was created
|
|
- pr_number: Integer PR number (if success)
|
|
- pr_url: String URL to created PR (if success)
|
|
- draft: Boolean indicating if PR is draft
|
|
- linked_issues: List of issue numbers auto-linked from commits
|
|
- error: Optional error message if failed
|
|
|
|
Raises:
|
|
ValueError: If current branch is main/master (cannot create PR from default branch)
|
|
ValueError: If no commits found to create PR
|
|
|
|
Example:
|
|
>>> result = create_pull_request(
|
|
... title="Add PR automation",
|
|
... reviewer="alice"
|
|
... )
|
|
>>> if result['success']:
|
|
... print(f"Created PR #{result['pr_number']}: {result['pr_url']}")
|
|
"""
|
|
# Validate prerequisites
|
|
valid, error_message = validate_gh_prerequisites()
|
|
if not valid:
|
|
return {
|
|
'success': False,
|
|
'error': error_message,
|
|
'pr_number': None,
|
|
'pr_url': None,
|
|
'draft': draft,
|
|
'linked_issues': []
|
|
}
|
|
|
|
# Get current branch if head not specified
|
|
if head is None:
|
|
try:
|
|
head = get_current_branch()
|
|
except subprocess.CalledProcessError as e:
|
|
return {
|
|
'success': False,
|
|
'error': f'Failed to get current branch: {e}',
|
|
'pr_number': None,
|
|
'pr_url': None,
|
|
'draft': draft,
|
|
'linked_issues': []
|
|
}
|
|
|
|
# Validate we're not on main/master branch
|
|
if head in ['main', 'master']:
|
|
raise ValueError(f'Cannot create PR from {head} branch. Switch to a feature branch first.')
|
|
|
|
# Check if there are commits to create PR from
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'log', f'{base}..{head}', '--oneline'],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
if not result.stdout.strip():
|
|
raise ValueError(f'No commits found between {base} and {head}. Nothing to create PR for.')
|
|
except subprocess.CalledProcessError:
|
|
# If git log fails, we can't check commits, so proceed anyway
|
|
# (may fail later during gh pr create)
|
|
pass
|
|
except subprocess.TimeoutExpired:
|
|
# If git log times out, we can't check commits, so proceed anyway
|
|
# (may fail later during gh pr create)
|
|
pass
|
|
|
|
# Parse commit messages for linked issues
|
|
linked_issues = parse_commit_messages_for_issues(base=base, head=head)
|
|
|
|
# Build gh pr create command
|
|
cmd = ['gh', 'pr', 'create']
|
|
|
|
# Add draft flag if requested
|
|
if draft:
|
|
cmd.append('--draft')
|
|
|
|
# Add base branch
|
|
cmd.extend(['--base', base])
|
|
|
|
# Add title and body (or use auto-fill)
|
|
if title is not None:
|
|
cmd.extend(['--title', title])
|
|
|
|
if body is not None:
|
|
cmd.extend(['--body', body])
|
|
|
|
# If no custom title/body, use auto-fill from commits
|
|
if title is None and body is None:
|
|
cmd.append('--fill-verbose')
|
|
|
|
# Add reviewer if specified
|
|
if reviewer is not None:
|
|
cmd.extend(['--reviewer', reviewer])
|
|
|
|
# Execute gh pr create command
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
|
|
# Parse PR URL from output (last line)
|
|
pr_url = result.stdout.strip().split('\n')[-1].strip()
|
|
|
|
# Extract PR number from URL
|
|
# URL format: https://github.com/owner/repo/pull/42
|
|
match = re.search(r'/pull/(\d+)', pr_url)
|
|
if match:
|
|
pr_number = int(match.group(1))
|
|
else:
|
|
pr_number = None
|
|
|
|
return {
|
|
'success': True,
|
|
'pr_number': pr_number,
|
|
'pr_url': pr_url,
|
|
'draft': draft,
|
|
'linked_issues': linked_issues,
|
|
'error': None
|
|
}
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
# Parse error message from stderr attribute
|
|
# CalledProcessError might have stderr as an attribute set by the test mock
|
|
error_msg = ''
|
|
if hasattr(e, 'stderr') and e.stderr:
|
|
error_msg = str(e.stderr)
|
|
else:
|
|
error_msg = str(e)
|
|
|
|
# Provide helpful error messages
|
|
if 'rate limit' in error_msg.lower():
|
|
error = 'GitHub API rate limit exceeded. Try again later.'
|
|
elif 'permission' in error_msg.lower() or 'protected' in error_msg.lower() or 'saml' in error_msg.lower():
|
|
error = f'Permission denied. Check repository permissions and SAML authorization: {error_msg}'
|
|
else:
|
|
error = f'Failed to create PR: {error_msg}'
|
|
|
|
return {
|
|
'success': False,
|
|
'error': error,
|
|
'pr_number': None,
|
|
'pr_url': None,
|
|
'draft': draft,
|
|
'linked_issues': linked_issues
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {
|
|
'success': False,
|
|
'error': 'GitHub CLI command timeout after 30 seconds. Check network connection.',
|
|
'pr_number': None,
|
|
'pr_url': None,
|
|
'draft': draft,
|
|
'linked_issues': linked_issues
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'error': f'Unexpected error creating PR: {e}',
|
|
'pr_number': None,
|
|
'pr_url': None,
|
|
'draft': draft,
|
|
'linked_issues': linked_issues
|
|
}
|
|
|
|
|
|
class PrAutomation:
|
|
"""
|
|
Object-oriented wrapper for PR automation functions.
|
|
|
|
Provides a class-based interface to pull request automation.
|
|
All methods are static/class methods that delegate to module functions.
|
|
"""
|
|
|
|
@staticmethod
|
|
def validate_prerequisites() -> Tuple[bool, str]:
|
|
"""Validate GitHub CLI and repository prerequisites."""
|
|
return validate_gh_prerequisites()
|
|
|
|
@staticmethod
|
|
def get_branch() -> str:
|
|
"""Get current git branch name."""
|
|
return get_current_branch()
|
|
|
|
@staticmethod
|
|
def extract_issues(messages: List[str]) -> List[int]:
|
|
"""Extract issue numbers from commit messages."""
|
|
return extract_issue_numbers(messages)
|
|
|
|
@staticmethod
|
|
def parse_commits(base: str = 'main', head: Optional[str] = None) -> List[int]:
|
|
"""Parse commit messages for issue references."""
|
|
return parse_commit_messages_for_issues(base, head)
|
|
|
|
@staticmethod
|
|
def create_pr(
|
|
title: str,
|
|
body: str,
|
|
base: str = 'main',
|
|
head: Optional[str] = None,
|
|
draft: bool = False,
|
|
auto_link_issues: bool = True
|
|
) -> Dict[str, Any]:
|
|
"""Create pull request using GitHub CLI."""
|
|
return create_pull_request(title, body, base, head, draft, auto_link_issues)
|