994 lines
38 KiB
Python
994 lines
38 KiB
Python
"""
|
|
Main orchestrator for AI agent execution.
|
|
Executes a single task per run: read task -> execute -> test -> commit
|
|
"""
|
|
import sys
|
|
import subprocess
|
|
import time
|
|
import json
|
|
import re
|
|
import urllib.request
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, List, Tuple
|
|
|
|
from config import AgentConfig, get_test_command, get_lint_command
|
|
from task_manager import TaskManager, GlobalLock, Task
|
|
from cursor_cli_wrapper import CursorCLI, CursorResultStatus
|
|
from planning_agent import PlanningAgent
|
|
|
|
|
|
class AgentOrchestrator:
|
|
"""Orchestrates the agent development cycle."""
|
|
|
|
def __init__(self, config: AgentConfig):
|
|
self.config = config
|
|
self.task_manager = TaskManager(
|
|
config.task_list_path,
|
|
config.agent_state_path
|
|
)
|
|
self.global_lock = GlobalLock(config.global_lock_path)
|
|
self.cursor_cli = CursorCLI(
|
|
project_root=config.project_root,
|
|
api_key=config.cursor_api_key,
|
|
model=config.cursor_model,
|
|
verbose=True
|
|
)
|
|
|
|
def run(self) -> int:
|
|
"""
|
|
Execute a single task from the task list.
|
|
Returns: 0 on success, 1 on error
|
|
"""
|
|
print(f"🚀 Starting AI Agent for component: {self.config.component}")
|
|
print(f"📁 Project root: {self.config.project_root}")
|
|
print(f"🎯 Executing one task per run")
|
|
|
|
# Check if another agent is running
|
|
if self.task_manager.is_agent_running():
|
|
print(f"⚠️ Another agent is already running for {self.config.component}")
|
|
print("Exiting to avoid conflicts.")
|
|
return 0
|
|
|
|
try:
|
|
state = self.task_manager.load_state()
|
|
|
|
# Reset stale in_progress tasks (tasks that were left in_progress from previous runs)
|
|
self.task_manager.reset_stale_in_progress_tasks(state.current_task_id)
|
|
|
|
# Check iteration limit
|
|
if state.iteration_count >= self.config.max_iterations:
|
|
print(f"\n⏹️ Reached maximum iterations ({self.config.max_iterations})")
|
|
return 0
|
|
|
|
# Get next task
|
|
next_task = self.task_manager.get_next_task()
|
|
|
|
if not next_task:
|
|
print("\n✅ All tasks completed or no pending tasks!")
|
|
|
|
# Trigger planning agent to generate new tasks
|
|
print("\n🎯 No tasks available, triggering planning agent...")
|
|
planning_agent = PlanningAgent(self.config)
|
|
planning_result = planning_agent.run()
|
|
|
|
if planning_result == 0:
|
|
print("\n✅ Planning agent completed successfully")
|
|
print(" New tasks have been generated. Checking for available tasks...")
|
|
|
|
# Try to get next task again after planning
|
|
next_task = self.task_manager.get_next_task()
|
|
|
|
if next_task:
|
|
print(f"\n📋 Found new task: {next_task.id} - {next_task.title}")
|
|
print(" Proceeding to execute the new task...")
|
|
# Continue with task execution (don't return, fall through to task execution)
|
|
else:
|
|
print("\n⚠️ Planning agent completed but no new tasks were generated")
|
|
print(" Agent will exit. You may need to manually create tasks.")
|
|
self.task_manager.reset_state()
|
|
return 0
|
|
else:
|
|
print("\n⚠️ Planning agent failed")
|
|
print(" Agent will exit. You may need to manually trigger planning or create tasks")
|
|
self.task_manager.reset_state()
|
|
return 0
|
|
|
|
# At this point we should have a task to execute
|
|
# Continue with task execution
|
|
print(f"\n{'='*80}")
|
|
print(f"📋 Task: {next_task.id}")
|
|
print(f"📝 {next_task.title}")
|
|
print(f"⏱️ Estimated: {next_task.estimated_hours}h")
|
|
print(f"{'='*80}\n")
|
|
|
|
# Start task (only if not already in progress)
|
|
if next_task.status != "in_progress":
|
|
self.task_manager.start_task(next_task)
|
|
|
|
# Check if task needs global lock (modifies common package)
|
|
needs_global_lock = self._needs_global_lock(next_task)
|
|
if needs_global_lock:
|
|
if not self._acquire_global_lock():
|
|
print("⚠️ Cannot acquire global lock, skipping task")
|
|
self.task_manager.mark_task_skipped(
|
|
next_task.id,
|
|
"Global lock not available"
|
|
)
|
|
return 0
|
|
|
|
try:
|
|
# Retry loop: keep trying the same task until success or max retries
|
|
while True:
|
|
# Get current retry count
|
|
current_state = self.task_manager.load_state()
|
|
retry_count = current_state.retry_count
|
|
|
|
if retry_count > 0:
|
|
print(f"\n🔄 Retry attempt {retry_count}/{self.config.max_retries} for task {next_task.id}")
|
|
|
|
# Execute task
|
|
success = self._execute_task(next_task)
|
|
|
|
if success:
|
|
# Task completed successfully
|
|
print(f"\n✅ Task {next_task.id} completed successfully")
|
|
self.task_manager.mark_task_completed(next_task.id)
|
|
break
|
|
else:
|
|
# Task failed, increment retry count
|
|
retry_count = self.task_manager.increment_retry()
|
|
|
|
if retry_count >= self.config.max_retries:
|
|
print(f"\n❌ Task {next_task.id} failed after {retry_count} retries")
|
|
|
|
# Try to break down the task into subtasks
|
|
print(f"\n🔀 Attempting to break down task {next_task.id} into subtasks...")
|
|
planning_agent = PlanningAgent(self.config)
|
|
|
|
failure_reason = f"Failed after {retry_count} retry attempts. Task may be too complex."
|
|
breakdown_success = planning_agent.break_down_task(
|
|
next_task,
|
|
failure_reason
|
|
)
|
|
|
|
if breakdown_success:
|
|
print(f"\n✅ Task {next_task.id} successfully broken down into subtasks")
|
|
print(" Subtasks have been added to task_list.json")
|
|
# Mark original task as failed
|
|
self.task_manager.mark_task_failed(
|
|
next_task.id,
|
|
f"Failed after {retry_count} retries. Broken down into subtasks."
|
|
)
|
|
else:
|
|
print(f"\n⚠️ Failed to break down task {next_task.id}")
|
|
print(" Marking task as failed without subtasks")
|
|
self.task_manager.mark_task_failed(
|
|
next_task.id,
|
|
f"Failed after {retry_count} retries"
|
|
)
|
|
|
|
# Create GitHub issue
|
|
self._create_issue_for_failed_task(next_task)
|
|
|
|
break
|
|
else:
|
|
print(f"\n⚠️ Task {next_task.id} failed, retrying ({retry_count}/{self.config.max_retries})")
|
|
print("Waiting before retry...")
|
|
time.sleep(5) # Small delay before retry
|
|
|
|
finally:
|
|
# Release global lock if held
|
|
if needs_global_lock:
|
|
self.global_lock.release(self.config.component)
|
|
|
|
print("\n🎉 Agent execution completed (one task)")
|
|
return 0
|
|
|
|
except KeyboardInterrupt:
|
|
print("\n⚠️ Interrupted by user")
|
|
self.task_manager.reset_state()
|
|
return 1
|
|
|
|
except Exception as e:
|
|
print(f"\n❌ Fatal error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return 1
|
|
|
|
def _needs_global_lock(self, task: Task) -> bool:
|
|
"""Check if task needs global lock (modifies shared resources)."""
|
|
# Check if any files to modify are in mnemo_cards_common
|
|
for file_path in task.files_to_modify:
|
|
if "mnemo_cards_common" in file_path:
|
|
return True
|
|
return False
|
|
|
|
def _acquire_global_lock(self, timeout_minutes: int = 30) -> bool:
|
|
"""Try to acquire global lock with timeout."""
|
|
start_time = time.time()
|
|
|
|
while True:
|
|
if self.global_lock.acquire(
|
|
self.config.component,
|
|
f"Working on {self.config.component}"
|
|
):
|
|
print("🔒 Acquired global lock")
|
|
return True
|
|
|
|
elapsed_minutes = (time.time() - start_time) / 60
|
|
if elapsed_minutes >= timeout_minutes:
|
|
return False
|
|
|
|
print(f"⏳ Waiting for global lock... ({elapsed_minutes:.1f}/{timeout_minutes} min)")
|
|
time.sleep(30) # Check every 30 seconds
|
|
|
|
def _execute_task(self, task: Task) -> bool:
|
|
"""
|
|
Execute a single task.
|
|
Returns: True if successful, False otherwise
|
|
"""
|
|
# Load prompt template
|
|
prompt = self._build_task_prompt(task)
|
|
|
|
# Run cursor agent
|
|
print("🤖 Running Cursor Agent...")
|
|
result = self.cursor_cli.run_agent(
|
|
task_description=prompt,
|
|
force=True,
|
|
max_iterations=5
|
|
)
|
|
|
|
if result.status != CursorResultStatus.SUCCESS:
|
|
print(f"❌ Cursor agent failed: {result.error}")
|
|
return False
|
|
|
|
print(f"\n📊 Agent completed:")
|
|
print(f" - Files created: {len(result.files_created)}")
|
|
print(f" - Files modified: {len(result.files_modified)}")
|
|
print(f" - Files read: {len(result.files_read)}")
|
|
print(f" - Tool calls: {result.tool_calls}")
|
|
|
|
# Check if any files were actually changed
|
|
if len(result.files_created) == 0 and len(result.files_modified) == 0:
|
|
print("\n⚠️ WARNING: Agent completed but no files were created or modified!")
|
|
print(f" Agent read {len(result.files_read)} file(s) but made no changes.")
|
|
print(" This might indicate:")
|
|
print(" - Task was already completed")
|
|
print(" - Agent only analyzed files without making changes")
|
|
print(" - Agent encountered an issue but didn't report it")
|
|
|
|
# Check git status to see if there are any uncommitted changes
|
|
git_changes = self._check_git_changes()
|
|
if git_changes:
|
|
print(f"\n However, git shows {len(git_changes)} uncommitted change(s):")
|
|
for change in git_changes[:5]: # Show first 5
|
|
print(f" - {change}")
|
|
if len(git_changes) > 5:
|
|
print(f" ... and {len(git_changes) - 5} more")
|
|
else:
|
|
print("\n Git confirms: no changes detected in working directory.")
|
|
print(" Task may need to be retried or task description clarified.")
|
|
|
|
# Pull latest changes before committing
|
|
print("\n🔄 Pulling latest changes...")
|
|
self._git_pull()
|
|
|
|
# Run linter
|
|
print("🔍 Linter is disabled for now")
|
|
# print("\n🔍 Running linter...")
|
|
# if not self._run_lint():
|
|
# print("⚠️ Linter found issues")
|
|
# # Don't fail, cursor can fix in retry
|
|
# return False
|
|
|
|
# Run tests
|
|
print("Tests are disabled for now")
|
|
# print("\n🧪 Running tests...")
|
|
# if not self._run_tests():
|
|
# print("❌ Tests failed")
|
|
# return False
|
|
|
|
# Commit changes
|
|
print("\n💾 Committing changes...")
|
|
commit_message = self._build_commit_message(task, result)
|
|
if not self._git_commit(commit_message):
|
|
print("⚠️ No changes to commit")
|
|
|
|
# Push changes (with pull before push to avoid conflicts)
|
|
# If push fails, _git_push() will automatically create a PR
|
|
print("\n📤 Pushing changes...")
|
|
push_success = self._git_push()
|
|
|
|
if not push_success:
|
|
print("⚠️ Push failed, attempting to create PR...")
|
|
# _git_push() already handles PR creation, but if it still failed,
|
|
# we'll continue anyway as the changes are committed locally
|
|
print("⚠️ Changes are committed locally but not pushed")
|
|
print(" You may need to manually push or create a PR")
|
|
|
|
return True
|
|
|
|
def _build_task_prompt(self, task: Task) -> str:
|
|
"""Build comprehensive prompt for the agent."""
|
|
# Load development prompt template
|
|
prompt_file = self.config.prompts_dir / "development_prompt.md"
|
|
|
|
if prompt_file.exists():
|
|
with open(prompt_file, 'r') as f:
|
|
template = f.read()
|
|
else:
|
|
template = "You are an AI software engineer. Complete the following task:\n\n"
|
|
|
|
# Add task details
|
|
prompt = f"""{template}
|
|
|
|
## TASK: {task.id} - {task.title}
|
|
|
|
### Priority: {task.priority.upper()}
|
|
|
|
### Description:
|
|
{task.description}
|
|
|
|
### Acceptance Criteria:
|
|
"""
|
|
|
|
for i, criterion in enumerate(task.acceptance_criteria, 1):
|
|
prompt += f"{i}. {criterion}\n"
|
|
|
|
prompt += f"""
|
|
|
|
### Files to Modify:
|
|
"""
|
|
|
|
for file_path in task.files_to_modify:
|
|
prompt += f"- {file_path}\n"
|
|
|
|
prompt += f"""
|
|
|
|
### Component: {task.component}
|
|
|
|
### Working Directory: {self.config.component_root}
|
|
|
|
---
|
|
|
|
Complete this task following clean architecture principles and project conventions.
|
|
Write comprehensive unit tests for all new functionality.
|
|
Make sure all existing tests continue to pass.
|
|
"""
|
|
|
|
return prompt
|
|
|
|
def _build_commit_message(self, task: Task, result) -> str:
|
|
"""Build descriptive commit message."""
|
|
message = f"""feat({self.config.component}): {task.title}
|
|
|
|
Task ID: {task.id}
|
|
Priority: {task.priority}
|
|
|
|
Changes:
|
|
"""
|
|
|
|
if result.files_created:
|
|
message += f"- Created {len(result.files_created)} file(s)\n"
|
|
for file in result.files_created[:5]: # Limit to 5
|
|
message += f" - {file}\n"
|
|
|
|
if result.files_modified:
|
|
message += f"- Modified {len(result.files_modified)} file(s)\n"
|
|
for file in result.files_modified[:5]: # Limit to 5
|
|
message += f" - {file}\n"
|
|
|
|
message += f"\nCompleted by: AI Agent\n"
|
|
message += f"Duration: {result.duration_ms}ms\n"
|
|
|
|
return message
|
|
|
|
def _run_lint(self) -> bool:
|
|
"""Run linter for component."""
|
|
cmd = get_lint_command(self.config.component)
|
|
result = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
print("✅ Linter passed")
|
|
return True
|
|
else:
|
|
print(f"⚠️ Linter output:\n{result.stdout}")
|
|
return False
|
|
|
|
def _run_tests(self) -> bool:
|
|
"""Run tests for component."""
|
|
cmd = get_test_command(self.config.component)
|
|
result = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
print("✅ All tests passed")
|
|
return True
|
|
else:
|
|
print(f"❌ Test output:\n{result.stdout}")
|
|
return False
|
|
|
|
def _check_git_changes(self) -> List[str]:
|
|
"""Check for uncommitted changes in git."""
|
|
# Check status for modified, added, deleted files
|
|
result = subprocess.run(
|
|
["git", "status", "--porcelain"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return []
|
|
|
|
changes = []
|
|
for line in result.stdout.strip().split('\n'):
|
|
if line.strip():
|
|
# Format: "XY filename" where X is staging area, Y is working tree
|
|
# We care about files that are modified (M), added (A), deleted (D), or renamed (R)
|
|
status = line[:2]
|
|
filename = line[3:].strip()
|
|
|
|
if any(c in status for c in ['M', 'A', 'D', 'R']):
|
|
changes.append(filename)
|
|
|
|
return changes
|
|
|
|
def _git_pull(self) -> bool:
|
|
"""Pull latest changes from remote."""
|
|
result = subprocess.run(
|
|
["git", "pull", "--rebase"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
return result.returncode == 0
|
|
|
|
def _git_commit(self, message: str) -> bool:
|
|
"""Commit changes."""
|
|
# Add all changes
|
|
subprocess.run(
|
|
["git", "add", "-A"],
|
|
cwd=self.config.project_root
|
|
)
|
|
|
|
# Commit
|
|
result = subprocess.run(
|
|
["git", "commit", "-m", message],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
# Get commit hash
|
|
commit_hash = subprocess.run(
|
|
["git", "rev-parse", "HEAD"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
).stdout.strip()
|
|
|
|
# Update state with commit hash
|
|
state = self.task_manager.load_state()
|
|
state.last_commit = commit_hash
|
|
self.task_manager.save_state(state)
|
|
|
|
print(f"✅ Committed: {commit_hash[:8]}")
|
|
return True
|
|
else:
|
|
# No changes or error
|
|
return False
|
|
|
|
def _get_git_repo_info(self) -> Tuple[Optional[str], Optional[str]]:
|
|
"""Get repository owner and name from git remote."""
|
|
result = subprocess.run(
|
|
["git", "remote", "get-url", "origin"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return None, None
|
|
|
|
remote_url = result.stdout.strip()
|
|
|
|
# Parse different URL formats:
|
|
# https://forgejo.example.com/owner/repo.git
|
|
# https://forgejo.example.com/owner/repo
|
|
# git@forgejo.example.com:owner/repo.git
|
|
# https://github.com/owner/repo.git
|
|
|
|
patterns = [
|
|
r'https?://[^/]+/([^/]+)/([^/]+?)(?:\.git)?/?$',
|
|
r'git@[^:]+:([^/]+)/([^/]+?)(?:\.git)?$',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, remote_url)
|
|
if match:
|
|
owner = match.group(1)
|
|
repo = match.group(2)
|
|
return owner, repo
|
|
|
|
return None, None
|
|
|
|
def _get_forgejo_api_url(self) -> Optional[str]:
|
|
"""Get Forgejo/GitHub API URL from git remote."""
|
|
if self.config.forgejo_api_url:
|
|
return self.config.forgejo_api_url
|
|
|
|
result = subprocess.run(
|
|
["git", "remote", "get-url", "origin"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return None
|
|
|
|
remote_url = result.stdout.strip()
|
|
|
|
# Extract base URL
|
|
# https://forgejo.example.com/owner/repo.git -> https://forgejo.example.com
|
|
# https://github.com/owner/repo.git -> https://api.github.com
|
|
# git@forgejo.example.com:owner/repo.git -> https://forgejo.example.com
|
|
|
|
if 'github.com' in remote_url:
|
|
return 'https://api.github.com'
|
|
|
|
# For Forgejo, extract the base URL
|
|
match = re.search(r'https?://([^/]+)', remote_url)
|
|
if match:
|
|
base_url = f"https://{match.group(1)}"
|
|
return f"{base_url}/api/v1"
|
|
|
|
match = re.search(r'git@([^:]+)', remote_url)
|
|
if match:
|
|
base_url = f"https://{match.group(1)}"
|
|
return f"{base_url}/api/v1"
|
|
|
|
return None
|
|
|
|
def _check_existing_pr(self, branch_name: str) -> Optional[str]:
|
|
"""Check if PR already exists for this branch. Returns PR URL if found."""
|
|
if not self.config.forgejo_token:
|
|
return None
|
|
|
|
owner, repo = self._get_git_repo_info()
|
|
if not owner or not repo:
|
|
return None
|
|
|
|
api_url = self._get_forgejo_api_url()
|
|
if not api_url:
|
|
return None
|
|
|
|
# List open PRs for this branch
|
|
pr_url = f"{api_url}/repos/{owner}/{repo}/pulls?head={owner}:{branch_name}&state=open"
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
pr_url,
|
|
headers={
|
|
"Authorization": f"token {self.config.forgejo_token}",
|
|
"Accept": "application/json"
|
|
}
|
|
)
|
|
|
|
with urllib.request.urlopen(req) as response:
|
|
if response.status == 200:
|
|
prs = json.loads(response.read().decode('utf-8'))
|
|
if prs and len(prs) > 0:
|
|
return prs[0].get('html_url')
|
|
except Exception:
|
|
# If check fails, continue anyway
|
|
pass
|
|
|
|
return None
|
|
|
|
def _create_pull_request(self, task: Task, branch_name: str) -> bool:
|
|
"""Create a pull request via Forgejo/GitHub API."""
|
|
if not self.config.forgejo_token:
|
|
print("⚠️ FORGEJO_TOKEN not set, cannot create PR")
|
|
return False
|
|
|
|
owner, repo = self._get_git_repo_info()
|
|
if not owner or not repo:
|
|
print("⚠️ Could not determine repository owner/name from git remote")
|
|
return False
|
|
|
|
api_url = self._get_forgejo_api_url()
|
|
if not api_url:
|
|
print("⚠️ Could not determine API URL from git remote")
|
|
return False
|
|
|
|
# Check if PR already exists
|
|
existing_pr = self._check_existing_pr(branch_name)
|
|
if existing_pr:
|
|
print(f"✅ Pull Request already exists: {existing_pr}")
|
|
return True
|
|
|
|
# Get current branch name (should match branch_name, but verify)
|
|
branch_result = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if branch_result.returncode != 0:
|
|
print("⚠️ Could not determine current branch")
|
|
return False
|
|
|
|
current_branch = branch_result.stdout.strip()
|
|
|
|
# Use the provided branch_name, not current_branch (they should match)
|
|
if current_branch != branch_name:
|
|
print(f"⚠️ Branch mismatch: current={current_branch}, expected={branch_name}")
|
|
# Use current_branch for PR creation
|
|
branch_name = current_branch
|
|
|
|
# PR title and body
|
|
pr_title = f"feat({self.config.component}): {task.title}"
|
|
pr_body = f"""## Task: {task.id}
|
|
|
|
**Priority:** {task.priority}
|
|
|
|
### Description
|
|
{task.description}
|
|
|
|
### Changes
|
|
This PR contains changes for task {task.id} completed by AI Agent.
|
|
|
|
### Acceptance Criteria
|
|
"""
|
|
for i, criterion in enumerate(task.acceptance_criteria, 1):
|
|
pr_body += f"{i}. {criterion}\n"
|
|
|
|
pr_body += f"""
|
|
---
|
|
*Created automatically by AI Agent*
|
|
*Component: {self.config.component}*
|
|
"""
|
|
|
|
# Create PR via API
|
|
pr_url = f"{api_url}/repos/{owner}/{repo}/pulls"
|
|
|
|
data = {
|
|
"title": pr_title,
|
|
"body": pr_body,
|
|
"head": current_branch,
|
|
"base": "master"
|
|
}
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
pr_url,
|
|
data=json.dumps(data).encode('utf-8'),
|
|
headers={
|
|
"Authorization": f"token {self.config.forgejo_token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json"
|
|
}
|
|
)
|
|
|
|
with urllib.request.urlopen(req) as response:
|
|
if response.status in [200, 201]:
|
|
result = json.loads(response.read().decode('utf-8'))
|
|
pr_number = result.get('number', 'unknown')
|
|
pr_html_url = result.get('html_url', pr_url)
|
|
print(f"✅ Created Pull Request #{pr_number}")
|
|
print(f" URL: {pr_html_url}")
|
|
return True
|
|
else:
|
|
error_body = response.read().decode('utf-8')
|
|
print(f"❌ Failed to create PR: HTTP {response.status}")
|
|
print(f" Response: {error_body}")
|
|
return False
|
|
|
|
except urllib.error.HTTPError as e:
|
|
error_body = e.read().decode('utf-8')
|
|
print(f"❌ Failed to create PR: HTTP {e.code}")
|
|
print(f" Response: {error_body}")
|
|
return False
|
|
except Exception as e:
|
|
print(f"❌ Failed to create PR: {e}")
|
|
return False
|
|
|
|
git def _create_branch_and_pr(self, task: Task) -> bool:
|
|
"""Create a feature branch and PR for the task."""
|
|
# Get current task if not provided
|
|
if not task:
|
|
state = self.task_manager.load_state()
|
|
if not state.current_task_id:
|
|
print("⚠️ No current task ID, cannot create PR")
|
|
return False
|
|
|
|
tasks = self.task_manager.load_tasks()
|
|
task = next((t for t in tasks if t.id == state.current_task_id), None)
|
|
if not task:
|
|
print("⚠️ Current task not found, cannot create PR")
|
|
return False
|
|
|
|
# Create branch name from task ID
|
|
branch_name = f"agent/{self.config.component}/{task.id}"
|
|
branch_name = re.sub(r'[^a-zA-Z0-9/-]', '-', branch_name)
|
|
|
|
# Get current branch name
|
|
branch_result = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
|
|
|
|
# If we're already on the target branch, just push it
|
|
if current_branch == branch_name:
|
|
print(f"✅ Already on branch: {branch_name}")
|
|
else:
|
|
# Check if branch already exists locally
|
|
check_branch = subprocess.run(
|
|
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if check_branch.returncode == 0:
|
|
# Branch exists locally, just checkout
|
|
print(f"⚠️ Branch {branch_name} already exists locally, checking out...")
|
|
branch_result = subprocess.run(
|
|
["git", "checkout", branch_name],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
else:
|
|
# Create and checkout new branch
|
|
branch_result = subprocess.run(
|
|
["git", "checkout", "-b", branch_name],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if branch_result.returncode != 0:
|
|
print(f"⚠️ Failed to create/checkout branch: {branch_result.stderr}")
|
|
return False
|
|
|
|
print(f"✅ Created/checked out branch: {branch_name}")
|
|
|
|
# Push branch to remote
|
|
push_branch_result = subprocess.run(
|
|
["git", "push", "-u", "origin", branch_name],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if push_branch_result.returncode != 0:
|
|
print(f"⚠️ Failed to push branch: {push_branch_result.stderr}")
|
|
return False
|
|
|
|
print(f"✅ Pushed branch to remote")
|
|
|
|
# Create PR
|
|
if self._create_pull_request(task, branch_name):
|
|
print("✅ Successfully created PR")
|
|
return True
|
|
else:
|
|
print("⚠️ Failed to create PR, but branch was pushed")
|
|
return False
|
|
|
|
def _git_push(self) -> bool:
|
|
"""Push changes to remote. Behavior depends on commit_mode:
|
|
- 'pr': Always create PR (don't try to push to master)
|
|
- 'master': Try to push to master, create PR as fallback if push fails
|
|
"""
|
|
# Get current branch name
|
|
branch_result = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
|
|
|
|
# If commit_mode is 'pr', always create PR (don't try to push to master)
|
|
if self.config.commit_mode == "pr":
|
|
print("\n📝 Commit mode is 'pr', creating PR instead of pushing to master...")
|
|
|
|
# Get current task
|
|
state = self.task_manager.load_state()
|
|
if not state.current_task_id:
|
|
print("⚠️ No current task ID, cannot create PR")
|
|
return False
|
|
|
|
tasks = self.task_manager.load_tasks()
|
|
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
|
|
if not current_task:
|
|
print("⚠️ Current task not found, cannot create PR")
|
|
return False
|
|
|
|
# If we're on master, create branch and PR
|
|
if current_branch == "master":
|
|
return self._create_branch_and_pr(current_task)
|
|
else:
|
|
# We're already on a feature branch, just push it and create PR
|
|
push_branch_result = subprocess.run(
|
|
["git", "push", "-u", "origin", current_branch],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if push_branch_result.returncode == 0:
|
|
print(f"✅ Pushed branch to remote")
|
|
if self._create_pull_request(current_task, current_branch):
|
|
return True
|
|
else:
|
|
print("⚠️ Failed to create PR, but branch was pushed")
|
|
return True # Still consider it success
|
|
else:
|
|
print(f"⚠️ Push failed: {push_branch_result.stderr}")
|
|
return False
|
|
|
|
# commit_mode is 'master' - try to push to master, fallback to PR if fails
|
|
# First, fetch to check if there are remote changes
|
|
fetch_result = subprocess.run(
|
|
["git", "fetch"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if fetch_result.returncode != 0:
|
|
print(f"⚠️ Failed to fetch: {fetch_result.stderr}")
|
|
# Continue anyway, might be network issue
|
|
|
|
# Check if local branch is behind remote
|
|
check_result = subprocess.run(
|
|
["git", "rev-list", "--count", "HEAD..origin/master"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if check_result.returncode == 0:
|
|
behind_count = check_result.stdout.strip()
|
|
if behind_count and int(behind_count) > 0:
|
|
print(f"⚠️ Local branch is {behind_count} commit(s) behind remote")
|
|
print(" Pulling latest changes before push...")
|
|
if not self._git_pull():
|
|
print("❌ Failed to pull, push may fail")
|
|
|
|
# If we're on master, try to push directly
|
|
if current_branch == "master":
|
|
# Now try to push
|
|
result = subprocess.run(
|
|
["git", "push"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
print("✅ Pushed to remote")
|
|
return True
|
|
else:
|
|
# If push failed due to remote changes, try pull and push again
|
|
if "fetch first" in result.stderr or "Updates were rejected" in result.stderr:
|
|
print("⚠️ Push rejected due to remote changes")
|
|
print(" Pulling and retrying push...")
|
|
if self._git_pull():
|
|
# Retry push after pull
|
|
retry_result = subprocess.run(
|
|
["git", "push"],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
if retry_result.returncode == 0:
|
|
print("✅ Pushed to remote after pull")
|
|
return True
|
|
else:
|
|
print(f"❌ Push failed after pull: {retry_result.stderr}")
|
|
# Fall through to create PR (fallback)
|
|
else:
|
|
print("❌ Failed to pull before retry")
|
|
# Fall through to create PR (fallback)
|
|
|
|
# Fallback: create branch and PR
|
|
print("\n📝 Push to master failed, creating branch and PR as fallback...")
|
|
|
|
# Get current task
|
|
state = self.task_manager.load_state()
|
|
if not state.current_task_id:
|
|
print("⚠️ No current task ID, cannot create PR")
|
|
return False
|
|
|
|
tasks = self.task_manager.load_tasks()
|
|
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
|
|
if not current_task:
|
|
print("⚠️ Current task not found, cannot create PR")
|
|
return False
|
|
|
|
return self._create_branch_and_pr(current_task)
|
|
else:
|
|
# We're already on a feature branch, just push it
|
|
print(f"\n📝 Pushing to feature branch: {current_branch}")
|
|
push_branch_result = subprocess.run(
|
|
["git", "push", "-u", "origin", current_branch],
|
|
cwd=self.config.project_root,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if push_branch_result.returncode == 0:
|
|
print(f"✅ Pushed branch to remote")
|
|
|
|
# Try to create PR if we have task info
|
|
state = self.task_manager.load_state()
|
|
if state.current_task_id:
|
|
tasks = self.task_manager.load_tasks()
|
|
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
|
|
if current_task:
|
|
self._create_pull_request(current_task, current_branch)
|
|
|
|
return True
|
|
else:
|
|
print(f"⚠️ Push failed: {push_branch_result.stderr}")
|
|
return False
|
|
|
|
def _create_issue_for_failed_task(self, task: Task) -> None:
|
|
"""Create GitHub/Forgejo issue for failed task."""
|
|
# TODO: Implement GitHub/Forgejo API integration
|
|
print(f"\n📋 TODO: Create issue for failed task {task.id}")
|
|
print(f" Title: Failed: {task.title}")
|
|
print(f" Description: Task failed after {self.config.max_retries} retries")
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python agent_orchestrator.py <component>")
|
|
print("Components: web_v2, backend, common")
|
|
sys.exit(1)
|
|
|
|
component = sys.argv[1]
|
|
|
|
# Load configuration
|
|
config = AgentConfig.from_env(component)
|
|
|
|
try:
|
|
config.validate()
|
|
except ValueError as e:
|
|
print(f"❌ Configuration error: {e}")
|
|
sys.exit(1)
|
|
|
|
# Create orchestrator and run
|
|
orchestrator = AgentOrchestrator(config)
|
|
exit_code = orchestrator.run()
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|