Enhancing AI workflow
This commit is contained in:
@@ -1,59 +0,0 @@
|
|||||||
# Flet Build Configuration
|
|
||||||
# This file provides additional build settings for the Flet application
|
|
||||||
|
|
||||||
# App metadata
|
|
||||||
[app]
|
|
||||||
name = "GitHub Pulse"
|
|
||||||
description = "GitHub automation workflows with AI assistance"
|
|
||||||
author = "TySP-Dev"
|
|
||||||
|
|
||||||
# Build settings
|
|
||||||
[build]
|
|
||||||
# Include these files/directories in the build
|
|
||||||
include_packages = [
|
|
||||||
"app_components",
|
|
||||||
"assets"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Exclude unnecessary files from build
|
|
||||||
exclude = [
|
|
||||||
"*.pyc",
|
|
||||||
"__pycache__",
|
|
||||||
"*.pyo",
|
|
||||||
"*.pyd",
|
|
||||||
".git",
|
|
||||||
".gitignore",
|
|
||||||
"venv",
|
|
||||||
".env",
|
|
||||||
"*.md",
|
|
||||||
"BUILD.md",
|
|
||||||
"SETUP.md",
|
|
||||||
"*.example"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Asset optimization
|
|
||||||
[assets]
|
|
||||||
# Optimize images during build
|
|
||||||
optimize_images = true
|
|
||||||
# Include app_components/assets directory
|
|
||||||
directories = [
|
|
||||||
"assets",
|
|
||||||
"app_components/assets"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Platform-specific settings
|
|
||||||
[android]
|
|
||||||
adaptive_icon_background = "#1976D2"
|
|
||||||
adaptive_icon_foreground = "assets/icon.png"
|
|
||||||
|
|
||||||
[ios]
|
|
||||||
info_plist_version = "1.0"
|
|
||||||
|
|
||||||
[macos]
|
|
||||||
info_plist_version = "1.0"
|
|
||||||
|
|
||||||
[windows]
|
|
||||||
console = false # Hide console window in production
|
|
||||||
|
|
||||||
[linux]
|
|
||||||
categories = ["Development", "Utility"]
|
|
||||||
@@ -187,6 +187,9 @@ linux/
|
|||||||
macos/
|
macos/
|
||||||
windows/
|
windows/
|
||||||
web/
|
web/
|
||||||
|
.flet
|
||||||
|
build/
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
# Configuration (generated during build, not in git)
|
# Configuration (generated during build, not in git)
|
||||||
# Note: pyproject.toml is now tracked for proper builds
|
# Note: pyproject.toml is now tracked for proper builds
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from .settings_dialog import SettingsDialog
|
|||||||
from .main_gui import MainGUI
|
from .main_gui import MainGUI
|
||||||
from .utils import Logger, PRNumberManager, ContentBuilders
|
from .utils import Logger, PRNumberManager, ContentBuilders
|
||||||
from .workflow import WorkflowManager, WorkflowItem, GitHubRepoFetcher
|
from .workflow import WorkflowManager, WorkflowItem, GitHubRepoFetcher
|
||||||
|
from .ai_action_planner import AIActionPlanner, ActionPlan
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ConfigManager',
|
'ConfigManager',
|
||||||
@@ -43,6 +44,8 @@ __all__ = [
|
|||||||
'WorkflowManager',
|
'WorkflowManager',
|
||||||
'WorkflowItem',
|
'WorkflowItem',
|
||||||
'GitHubRepoFetcher',
|
'GitHubRepoFetcher',
|
||||||
|
'AIActionPlanner',
|
||||||
|
'ActionPlan',
|
||||||
'__version__',
|
'__version__',
|
||||||
'__author__',
|
'__author__',
|
||||||
'__app_name__',
|
'__app_name__',
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
"""
|
||||||
|
AI Action Planner
|
||||||
|
Generates and executes action plans for GitHub issues and PRs using AI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from typing import List, Dict, Any, Optional, Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ActionPlan:
|
||||||
|
"""Represents an AI-generated action plan"""
|
||||||
|
|
||||||
|
def __init__(self, title: str, steps: List[Dict[str, Any]], context: Dict[str, Any]):
|
||||||
|
self.title = title
|
||||||
|
self.steps = steps # List of {description, file_path, changes, completed}
|
||||||
|
self.context = context # PR/Issue context
|
||||||
|
self.completed_steps = []
|
||||||
|
self.failed_steps = []
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert plan to dictionary"""
|
||||||
|
return {
|
||||||
|
'title': self.title,
|
||||||
|
'steps': self.steps,
|
||||||
|
'context': self.context,
|
||||||
|
'completed_steps': self.completed_steps,
|
||||||
|
'failed_steps': self.failed_steps
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'ActionPlan':
|
||||||
|
"""Create plan from dictionary"""
|
||||||
|
plan = cls(data['title'], data['steps'], data['context'])
|
||||||
|
plan.completed_steps = data.get('completed_steps', [])
|
||||||
|
plan.failed_steps = data.get('failed_steps', [])
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaProvider:
|
||||||
|
"""Simple Ollama API provider for AI action planning"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, model: str, logger):
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.model = model
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
def generate(self, prompt: str) -> Optional[str]:
|
||||||
|
"""Generate a response from Ollama"""
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False
|
||||||
|
},
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
return result.get('response', '')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"❌ Ollama API error: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
|
||||||
|
"""Make changes to file content using Ollama"""
|
||||||
|
# Try direct replacement first
|
||||||
|
if old_text and old_text.strip() in file_content:
|
||||||
|
return file_content.replace(old_text.strip(), new_text.strip())
|
||||||
|
|
||||||
|
# Use Ollama to make intelligent changes
|
||||||
|
prompt = f"""You are a code modification assistant. Modify the following file according to the instructions.
|
||||||
|
|
||||||
|
File: {file_path}
|
||||||
|
|
||||||
|
Current Content:
|
||||||
|
```
|
||||||
|
{file_content}
|
||||||
|
```
|
||||||
|
|
||||||
|
Instructions: {new_text}
|
||||||
|
{f'Additional context: {custom_instructions}' if custom_instructions else ''}
|
||||||
|
|
||||||
|
Return ONLY the complete modified file content. Do not include explanations or markdown code blocks."""
|
||||||
|
|
||||||
|
return self.generate(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
class AIActionPlanner:
|
||||||
|
"""Generates and executes action plans using AI"""
|
||||||
|
|
||||||
|
def __init__(self, ai_manager, logger, config_manager):
|
||||||
|
self.ai_manager = ai_manager
|
||||||
|
self.logger = logger
|
||||||
|
self.config_manager = config_manager
|
||||||
|
|
||||||
|
def generate_plan(self, item, custom_instructions: str = "") -> Optional[ActionPlan]:
|
||||||
|
"""
|
||||||
|
Generate an action plan for a PR or Issue
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: The PR or Issue (WorkflowItem object or dict)
|
||||||
|
custom_instructions: Optional user-provided instructions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ActionPlan object or None if generation failed
|
||||||
|
"""
|
||||||
|
# Handle both WorkflowItem objects and dictionaries
|
||||||
|
if hasattr(item, 'item_type'):
|
||||||
|
# It's a WorkflowItem object
|
||||||
|
item_type = item.item_type
|
||||||
|
item_number = item.number
|
||||||
|
title = item.title
|
||||||
|
body = item.body or ''
|
||||||
|
repo = getattr(item, 'repo', None)
|
||||||
|
else:
|
||||||
|
# It's a dictionary
|
||||||
|
item_type = item.get('type', 'unknown')
|
||||||
|
item_number = item.get('number')
|
||||||
|
title = item.get('title', 'Untitled')
|
||||||
|
body = item.get('body', '')
|
||||||
|
repo = item.get('repo')
|
||||||
|
|
||||||
|
self.logger.log(f"🤖 Generating action plan for {item_type} #{item_number}...")
|
||||||
|
|
||||||
|
# Get AI provider
|
||||||
|
config = self.config_manager.get_config()
|
||||||
|
ai_provider_name = config.get('AI_PROVIDER', 'none').lower()
|
||||||
|
|
||||||
|
if ai_provider_name == 'none' or not ai_provider_name:
|
||||||
|
self.logger.log("❌ No AI provider configured. Please configure in Settings.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get provider instance
|
||||||
|
provider = self._get_ai_provider(ai_provider_name, config)
|
||||||
|
if not provider:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Generate the plan using AI
|
||||||
|
try:
|
||||||
|
self.logger.log(f"📤 Calling AI provider: {type(provider).__name__}")
|
||||||
|
plan_text = self._call_ai_for_plan(provider, item_type, title, body, custom_instructions)
|
||||||
|
|
||||||
|
if not plan_text:
|
||||||
|
self.logger.log("❌ AI did not generate a plan (empty response)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.logger.log(f"📥 Received response from AI ({len(plan_text)} characters)")
|
||||||
|
self.logger.log(f"📄 Response preview: {plan_text[:200]}...")
|
||||||
|
|
||||||
|
# Parse the plan
|
||||||
|
self.logger.log("🔍 Parsing AI response into steps...")
|
||||||
|
steps = self._parse_plan(plan_text)
|
||||||
|
|
||||||
|
if not steps:
|
||||||
|
self.logger.log("❌ Could not parse action steps from AI response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get repo from item or config
|
||||||
|
if repo is None:
|
||||||
|
repo = config.get('GITHUB_REPO', '')
|
||||||
|
|
||||||
|
plan = ActionPlan(
|
||||||
|
title=f"Action Plan for {item_type.upper()} #{item_number}: {title}",
|
||||||
|
steps=steps,
|
||||||
|
context={
|
||||||
|
'item_type': item_type,
|
||||||
|
'item_number': item_number,
|
||||||
|
'item_title': title,
|
||||||
|
'item_body': body,
|
||||||
|
'repo': repo
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.log(f"✅ Generated plan with {len(steps)} steps")
|
||||||
|
return plan
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"❌ Error generating plan: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_ai_provider(self, provider_name: str, config: Dict[str, Any]):
|
||||||
|
"""Get the AI provider instance"""
|
||||||
|
try:
|
||||||
|
if provider_name in ['claude', 'anthropic']:
|
||||||
|
# Try both CLAUDE_API_KEY and ANTHROPIC_API_KEY for compatibility
|
||||||
|
api_key = config.get('CLAUDE_API_KEY')
|
||||||
|
if not api_key:
|
||||||
|
api_key = config.get('ANTHROPIC_API_KEY')
|
||||||
|
if not api_key:
|
||||||
|
self.logger.log("❌ Claude API key not found in secure storage (tried both CLAUDE_API_KEY and ANTHROPIC_API_KEY)")
|
||||||
|
return None
|
||||||
|
self.logger.log("ℹ️ Initializing Claude provider...")
|
||||||
|
from . import ai_manager
|
||||||
|
provider = ai_manager.ClaudeProvider(api_key, self.logger)
|
||||||
|
self.logger.log("✅ Claude provider initialized successfully")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
elif provider_name in ['chatgpt', 'openai']:
|
||||||
|
api_key = config.get('OPENAI_API_KEY')
|
||||||
|
if not api_key:
|
||||||
|
self.logger.log("❌ OpenAI API key not found in secure storage")
|
||||||
|
return None
|
||||||
|
self.logger.log("ℹ️ Initializing ChatGPT provider...")
|
||||||
|
from . import ai_manager
|
||||||
|
provider = ai_manager.ChatGPTProvider(api_key, self.logger)
|
||||||
|
self.logger.log("✅ ChatGPT provider initialized successfully")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
elif provider_name == 'ollama':
|
||||||
|
# Ollama doesn't need an API key, uses URL from config
|
||||||
|
ollama_url = config.get('OLLAMA_URL', 'http://localhost:11434')
|
||||||
|
ollama_model = config.get('OLLAMA_MODEL', 'llama2')
|
||||||
|
self.logger.log(f"ℹ️ Using Ollama at {ollama_url} with model {ollama_model}")
|
||||||
|
# Create a simple Ollama provider wrapper
|
||||||
|
return OllamaProvider(ollama_url, ollama_model, self.logger)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.log(f"❌ Unsupported AI provider: {provider_name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"❌ Error creating AI provider: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _call_ai_for_plan(self, provider, item_type: str, title: str, body: str, custom_instructions: str) -> Optional[str]:
|
||||||
|
"""Call AI to generate an action plan"""
|
||||||
|
|
||||||
|
prompt = f"""You are an expert software engineer tasked with creating an actionable plan to address a GitHub {item_type}.
|
||||||
|
|
||||||
|
{item_type.upper()} Title: {title}
|
||||||
|
|
||||||
|
{item_type.upper()} Description:
|
||||||
|
{body}
|
||||||
|
|
||||||
|
{"Additional Instructions: " + custom_instructions if custom_instructions else ""}
|
||||||
|
|
||||||
|
Please create a detailed action plan with specific, executable steps. For each step, specify:
|
||||||
|
1. What needs to be done (clear description)
|
||||||
|
2. Which file(s) need to be modified (if applicable)
|
||||||
|
3. What changes should be made (if applicable)
|
||||||
|
|
||||||
|
Format your response as a JSON array of steps, where each step has:
|
||||||
|
- "description": A clear description of what to do
|
||||||
|
- "file_path": Path to the file to modify (or null if not file-specific)
|
||||||
|
- "changes": Description of changes to make (or null if not applicable)
|
||||||
|
- "action_type": One of ["modify_file", "create_file", "delete_file", "investigate", "test", "document"]
|
||||||
|
|
||||||
|
Example format:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"description": "Fix the authentication bug in login handler",
|
||||||
|
"file_path": "src/auth/login.py",
|
||||||
|
"changes": "Update the password validation logic to handle special characters correctly",
|
||||||
|
"action_type": "modify_file"
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
"description": "Add unit tests for authentication",
|
||||||
|
"file_path": "tests/test_auth.py",
|
||||||
|
"changes": "Add test cases for special characters in passwords",
|
||||||
|
"action_type": "create_file"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
IMPORTANT: Return ONLY the JSON array, no other text before or after."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(provider, OllamaProvider):
|
||||||
|
# Use Ollama
|
||||||
|
self.logger.log(f"🤖 Calling Ollama AI to generate plan...")
|
||||||
|
return provider.generate(prompt)
|
||||||
|
|
||||||
|
elif hasattr(provider, '_generate_updated_document'):
|
||||||
|
# Use Claude's document generation
|
||||||
|
self.logger.log(f"🤖 Calling Claude AI to generate plan...")
|
||||||
|
import anthropic
|
||||||
|
client = anthropic.Anthropic(api_key=provider.api_key)
|
||||||
|
|
||||||
|
message = client.messages.create(
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
max_tokens=4096,
|
||||||
|
messages=[{"role": "user", "content": prompt}]
|
||||||
|
)
|
||||||
|
|
||||||
|
return message.content[0].text
|
||||||
|
|
||||||
|
elif hasattr(provider, 'client'):
|
||||||
|
# Use OpenAI/ChatGPT
|
||||||
|
self.logger.log(f"🤖 Calling ChatGPT AI to generate plan...")
|
||||||
|
response = provider.client.chat.completions.create(
|
||||||
|
model="gpt-4",
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
max_tokens=4096
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.log(f"✅ ChatGPT response received")
|
||||||
|
return response.choices[0].message.content
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.log(f"❌ Unknown provider type: {type(provider).__name__}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"❌ AI API call failed: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
self.logger.log(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_plan(self, plan_text: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse the AI-generated plan text into structured steps"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract JSON from response (might be wrapped in markdown)
|
||||||
|
json_match = re.search(r'```json\s*(\[.*?\])\s*```', plan_text, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
json_text = json_match.group(1)
|
||||||
|
else:
|
||||||
|
# Try to find JSON array directly
|
||||||
|
json_match = re.search(r'\[.*\]', plan_text, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
json_text = json_match.group(0)
|
||||||
|
else:
|
||||||
|
self.logger.log("⚠️ Could not find JSON in AI response")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse JSON
|
||||||
|
steps = json.loads(json_text)
|
||||||
|
|
||||||
|
# Validate and clean up steps
|
||||||
|
validated_steps = []
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if isinstance(step, dict):
|
||||||
|
validated_step = {
|
||||||
|
'step_number': i + 1,
|
||||||
|
'description': step.get('description', f'Step {i+1}'),
|
||||||
|
'file_path': step.get('file_path'),
|
||||||
|
'changes': step.get('changes'),
|
||||||
|
'action_type': step.get('action_type', 'investigate'),
|
||||||
|
'completed': False,
|
||||||
|
'status': 'pending'
|
||||||
|
}
|
||||||
|
validated_steps.append(validated_step)
|
||||||
|
|
||||||
|
self.logger.log(f"✅ Successfully parsed {len(validated_steps)} steps from AI response")
|
||||||
|
return validated_steps
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
self.logger.log(f"❌ Failed to parse JSON: {str(e)}")
|
||||||
|
self.logger.log(f"Response was: {plan_text[:500]}...")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"❌ Error parsing plan: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def execute_plan(
|
||||||
|
self,
|
||||||
|
plan: ActionPlan,
|
||||||
|
local_repo_path: str,
|
||||||
|
progress_callback: Optional[Callable[[int, int, str], None]] = None,
|
||||||
|
log_callback: Optional[Callable[[str], None]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute an action plan
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan: The ActionPlan to execute
|
||||||
|
local_repo_path: Path to local git repository
|
||||||
|
progress_callback: Callback function(current_step, total_steps, message)
|
||||||
|
log_callback: Callback function for logging thought process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with execution results
|
||||||
|
"""
|
||||||
|
def log(message):
|
||||||
|
"""Helper to log to both logger and callback"""
|
||||||
|
self.logger.log(message)
|
||||||
|
if log_callback:
|
||||||
|
log_callback(message)
|
||||||
|
|
||||||
|
log(f"▶️ Starting execution of plan: {plan.title}")
|
||||||
|
|
||||||
|
if not local_repo_path or not Path(local_repo_path).exists():
|
||||||
|
log(f"❌ Local repository path not found: {local_repo_path}")
|
||||||
|
return {'success': False, 'error': 'Invalid local repository path'}
|
||||||
|
|
||||||
|
total_steps = len(plan.steps)
|
||||||
|
completed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for i, step in enumerate(plan.steps):
|
||||||
|
step_num = step['step_number']
|
||||||
|
|
||||||
|
# Mark step as in-progress
|
||||||
|
step['status'] = 'in_progress'
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i + 1, total_steps, f"Executing step {step_num}...")
|
||||||
|
|
||||||
|
log(f"\n📍 Step {step_num}/{total_steps}: {step['description']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._execute_step(step, local_repo_path, plan.context, log)
|
||||||
|
|
||||||
|
if result['success']:
|
||||||
|
step['completed'] = True
|
||||||
|
step['status'] = 'completed'
|
||||||
|
plan.completed_steps.append(step_num)
|
||||||
|
completed += 1
|
||||||
|
log(f"✅ Step {step_num} completed")
|
||||||
|
else:
|
||||||
|
step['status'] = 'failed'
|
||||||
|
step['error'] = result.get('error', 'Unknown error')
|
||||||
|
plan.failed_steps.append(step_num)
|
||||||
|
failed += 1
|
||||||
|
log(f"❌ Step {step_num} failed: {result.get('error')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
step['status'] = 'failed'
|
||||||
|
step['error'] = str(e)
|
||||||
|
plan.failed_steps.append(step_num)
|
||||||
|
failed += 1
|
||||||
|
log(f"❌ Step {step_num} failed with exception: {str(e)}")
|
||||||
|
|
||||||
|
log(f"\n📊 Execution complete: {completed}/{total_steps} steps successful, {failed} failed")
|
||||||
|
|
||||||
|
# If we made changes successfully, commit and push them
|
||||||
|
if completed > 0:
|
||||||
|
try:
|
||||||
|
log("\n🔧 Committing and pushing changes...")
|
||||||
|
|
||||||
|
# Get PR/Issue info from context
|
||||||
|
item_type = plan.context.get('item_type', 'item')
|
||||||
|
item_number = plan.context.get('item_number', 'unknown')
|
||||||
|
item_title = plan.context.get('item_title', 'changes')
|
||||||
|
|
||||||
|
# Commit message
|
||||||
|
commit_msg = f"AI: Execute action plan for {item_type} #{item_number}\n\n{item_title}\n\nAutomated changes by GitHub Pulse AI"
|
||||||
|
|
||||||
|
# Get current branch (should be the PR branch)
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
cwd=local_repo_path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
current_branch = result.stdout.strip() if result.returncode == 0 else 'main'
|
||||||
|
log(f"📍 Current branch: {current_branch}")
|
||||||
|
|
||||||
|
# Stage all changes
|
||||||
|
log("📝 Staging changes...")
|
||||||
|
subprocess.run(['git', 'add', '-A'], cwd=local_repo_path, check=True, timeout=10)
|
||||||
|
|
||||||
|
# Check if there are changes to commit
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'diff', '--cached', '--quiet'],
|
||||||
|
cwd=local_repo_path,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0: # There are changes
|
||||||
|
# Commit
|
||||||
|
log("💾 Committing changes...")
|
||||||
|
subprocess.run(
|
||||||
|
['git', 'commit', '-m', commit_msg],
|
||||||
|
cwd=local_repo_path,
|
||||||
|
check=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Push
|
||||||
|
log(f"🚀 Pushing to {current_branch}...")
|
||||||
|
subprocess.run(
|
||||||
|
['git', 'push', 'origin', current_branch],
|
||||||
|
cwd=local_repo_path,
|
||||||
|
check=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
log(f"✅ Changes pushed to {current_branch}")
|
||||||
|
else:
|
||||||
|
log("ℹ️ No changes to commit")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log("⚠️ Git operation timed out")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
log(f"⚠️ Git operation failed: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"⚠️ Error during git commit/push: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': failed == 0,
|
||||||
|
'completed': completed,
|
||||||
|
'failed': failed,
|
||||||
|
'total': total_steps,
|
||||||
|
'plan': plan
|
||||||
|
}
|
||||||
|
|
||||||
|
def _execute_step(self, step: Dict[str, Any], local_repo_path: str, context: Dict[str, Any], log=None) -> Dict[str, Any]:
|
||||||
|
"""Execute a single step of the plan"""
|
||||||
|
|
||||||
|
action_type = step.get('action_type', 'investigate')
|
||||||
|
file_path = step.get('file_path')
|
||||||
|
changes = step.get('changes')
|
||||||
|
|
||||||
|
# Use log function if provided, otherwise fall back to logger
|
||||||
|
log_func = log if log else self.logger.log
|
||||||
|
|
||||||
|
if action_type == 'modify_file' and file_path:
|
||||||
|
return self._modify_file(file_path, changes, local_repo_path, log_func)
|
||||||
|
|
||||||
|
elif action_type == 'create_file' and file_path:
|
||||||
|
return self._create_file(file_path, changes, local_repo_path, log_func)
|
||||||
|
|
||||||
|
elif action_type == 'delete_file' and file_path:
|
||||||
|
return self._delete_file(file_path, local_repo_path, log_func)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# For investigate, test, document actions, just mark as completed
|
||||||
|
# (requires manual intervention)
|
||||||
|
log_func(f"ℹ️ Manual action required: {step['description']}")
|
||||||
|
return {'success': True, 'message': 'Manual action logged'}
|
||||||
|
|
||||||
|
def _modify_file(self, file_path: str, changes: str, local_repo_path: str, log=None) -> Dict[str, Any]:
|
||||||
|
"""Modify a file using AI"""
|
||||||
|
|
||||||
|
log_func = log if log else self.logger.log
|
||||||
|
full_path = Path(local_repo_path) / file_path
|
||||||
|
|
||||||
|
if not full_path.exists():
|
||||||
|
return {'success': False, 'error': f'File not found: {file_path}'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_func(f"📝 Reading file: {file_path}")
|
||||||
|
# Read current content
|
||||||
|
with open(full_path, 'r', encoding='utf-8') as f:
|
||||||
|
current_content = f.read()
|
||||||
|
|
||||||
|
# Get AI provider to make changes
|
||||||
|
config = self.config_manager.get_config()
|
||||||
|
provider_name = config.get('AI_PROVIDER', 'none').lower()
|
||||||
|
provider = self._get_ai_provider(provider_name, config)
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
return {'success': False, 'error': 'AI provider not available'}
|
||||||
|
|
||||||
|
# Use AI to make the changes
|
||||||
|
log_func(f"🤖 Using AI to modify {file_path}...")
|
||||||
|
log_func(f"🔍 Analyzing changes needed...")
|
||||||
|
updated_content = provider.make_change(
|
||||||
|
file_content=current_content,
|
||||||
|
old_text=current_content[:200] + "...", # Context
|
||||||
|
new_text=changes, # What to change
|
||||||
|
file_path=str(full_path),
|
||||||
|
custom_instructions=changes
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_content and updated_content != current_content:
|
||||||
|
log_func(f"💾 Writing changes to {file_path}...")
|
||||||
|
# Write updated content
|
||||||
|
with open(full_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(updated_content)
|
||||||
|
|
||||||
|
log_func(f"✅ Successfully modified {file_path}")
|
||||||
|
return {'success': True, 'file': file_path}
|
||||||
|
else:
|
||||||
|
return {'success': False, 'error': 'AI could not generate changes'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': f'Error modifying file: {str(e)}'}
|
||||||
|
|
||||||
|
def _create_file(self, file_path: str, content: str, local_repo_path: str, log=None) -> Dict[str, Any]:
|
||||||
|
"""Create a new file"""
|
||||||
|
|
||||||
|
log_func = log if log else self.logger.log
|
||||||
|
full_path = Path(local_repo_path) / file_path
|
||||||
|
|
||||||
|
if full_path.exists():
|
||||||
|
return {'success': False, 'error': f'File already exists: {file_path}'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_func(f"📄 Creating new file: {file_path}")
|
||||||
|
# Create parent directories if needed
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create file with content
|
||||||
|
with open(full_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content or f"# TODO: Implement {file_path}\n")
|
||||||
|
|
||||||
|
log_func(f"✅ Created {file_path}")
|
||||||
|
return {'success': True, 'file': file_path}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': f'Error creating file: {str(e)}'}
|
||||||
|
|
||||||
|
def _delete_file(self, file_path: str, local_repo_path: str, log=None) -> Dict[str, Any]:
|
||||||
|
"""Delete a file"""
|
||||||
|
|
||||||
|
log_func = log if log else self.logger.log
|
||||||
|
full_path = Path(local_repo_path) / file_path
|
||||||
|
|
||||||
|
if not full_path.exists():
|
||||||
|
return {'success': False, 'error': f'File not found: {file_path}'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_func(f"🗑️ Deleting file: {file_path}")
|
||||||
|
full_path.unlink()
|
||||||
|
log_func(f"✅ Deleted {file_path}")
|
||||||
|
return {'success': True, 'file': file_path}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': f'Error deleting file: {str(e)}'}
|
||||||
@@ -742,6 +742,12 @@ Current file content:
|
|||||||
class ChatGPTProvider(AIProvider):
|
class ChatGPTProvider(AIProvider):
|
||||||
"""ChatGPT/GPT-4 provider using OpenAI API"""
|
"""ChatGPT/GPT-4 provider using OpenAI API"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, logger: Logger):
|
||||||
|
"""Initialize ChatGPT provider with OpenAI client"""
|
||||||
|
super().__init__(api_key, logger)
|
||||||
|
import openai
|
||||||
|
self.client = openai.OpenAI(api_key=api_key)
|
||||||
|
|
||||||
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
|
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
|
||||||
"""Make smart, targeted changes based on reference text and suggestions
|
"""Make smart, targeted changes based on reference text and suggestions
|
||||||
|
|
||||||
@@ -774,8 +780,8 @@ class ChatGPTProvider(AIProvider):
|
|||||||
"""Generate updated document content using ChatGPT"""
|
"""Generate updated document content using ChatGPT"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import openai
|
# Use the client initialized in __init__
|
||||||
client = openai.OpenAI(api_key=self.api_key)
|
client = self.client
|
||||||
|
|
||||||
# Build custom instructions text
|
# Build custom instructions text
|
||||||
if custom_instructions and custom_instructions.strip():
|
if custom_instructions and custom_instructions.strip():
|
||||||
@@ -3358,16 +3364,19 @@ class AIManager:
|
|||||||
|
|
||||||
# Anthropic/Claude
|
# Anthropic/Claude
|
||||||
elif provider_name in ['claude', 'anthropic']:
|
elif provider_name in ['claude', 'anthropic']:
|
||||||
|
# Try both CLAUDE_API_KEY and ANTHROPIC_API_KEY for compatibility
|
||||||
|
api_key = config.get('CLAUDE_API_KEY', '')
|
||||||
|
if not api_key:
|
||||||
api_key = config.get('ANTHROPIC_API_KEY', '')
|
api_key = config.get('ANTHROPIC_API_KEY', '')
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return "Error: Anthropic API key not configured"
|
return "Error: Claude API key not configured (tried both CLAUDE_API_KEY and ANTHROPIC_API_KEY)"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import anthropic
|
import anthropic
|
||||||
client = anthropic.Anthropic(api_key=api_key)
|
client = anthropic.Anthropic(api_key=api_key)
|
||||||
|
|
||||||
response = client.messages.create(
|
response = client.messages.create(
|
||||||
model=config.get('ANTHROPIC_MODEL', 'claude-3-5-sonnet-20241022'),
|
model=config.get('ANTHROPIC_MODEL', 'claude-sonnet-4-5'),
|
||||||
max_tokens=2000,
|
max_tokens=2000,
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ class MainGUI:
|
|||||||
# Initialize logger
|
# Initialize logger
|
||||||
self.logger = None # Will be set after UI is created
|
self.logger = None # Will be set after UI is created
|
||||||
|
|
||||||
|
# AI Action Plan state
|
||||||
|
self.current_action_plan = None
|
||||||
|
self.plan_display_ref = ft.Ref[ft.Column]()
|
||||||
|
self.plan_progress_ref = ft.Ref[ft.ProgressBar]()
|
||||||
|
self.plan_status_ref = ft.Ref[ft.Text]()
|
||||||
|
self.execute_plan_button_ref = ft.Ref[ft.ElevatedButton]()
|
||||||
|
self.generate_plan_button_ref = ft.Ref[ft.ElevatedButton]()
|
||||||
|
self.ai_instructions_ref = ft.Ref[ft.TextField]()
|
||||||
|
|
||||||
# Register settings change listener for live updates
|
# Register settings change listener for live updates
|
||||||
self.config_manager.register_listener(self._on_settings_changed)
|
self.config_manager.register_listener(self._on_settings_changed)
|
||||||
|
|
||||||
@@ -457,6 +466,11 @@ class MainGUI:
|
|||||||
icon=ft.icons.DIFFERENCE,
|
icon=ft.icons.DIFFERENCE,
|
||||||
content=self._create_diff_tab()
|
content=self._create_diff_tab()
|
||||||
),
|
),
|
||||||
|
ft.Tab(
|
||||||
|
text="AI Action Plan",
|
||||||
|
icon=ft.icons.AUTO_AWESOME,
|
||||||
|
content=self._create_ai_plan_tab()
|
||||||
|
),
|
||||||
],
|
],
|
||||||
expand=True,
|
expand=True,
|
||||||
)
|
)
|
||||||
@@ -1538,6 +1552,10 @@ Description:
|
|||||||
# Store the active item
|
# Store the active item
|
||||||
self.active_workflow_item = item
|
self.active_workflow_item = item
|
||||||
|
|
||||||
|
# Enable the Generate Plan button when an item is selected
|
||||||
|
if self.generate_plan_button_ref.current:
|
||||||
|
self.generate_plan_button_ref.current.disabled = False
|
||||||
|
|
||||||
# Determine display labels
|
# Determine display labels
|
||||||
repo_label = "Target" if item.repo_source == "target" else "Fork"
|
repo_label = "Target" if item.repo_source == "target" else "Fork"
|
||||||
repo_color = ft.colors.BLUE if item.repo_source == "target" else ft.colors.PURPLE
|
repo_color = ft.colors.BLUE if item.repo_source == "target" else ft.colors.PURPLE
|
||||||
@@ -2683,6 +2701,409 @@ Description:
|
|||||||
self.diff_text_ref.current.value = diff_content
|
self.diff_text_ref.current.value = diff_content
|
||||||
self.page.update()
|
self.page.update()
|
||||||
|
|
||||||
|
def _create_ai_plan_tab(self) -> ft.Container:
|
||||||
|
"""Create the AI Action Plan tab"""
|
||||||
|
|
||||||
|
# Plan display area (initially empty)
|
||||||
|
plan_display = ft.Column(
|
||||||
|
ref=self.plan_display_ref,
|
||||||
|
controls=[
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column([
|
||||||
|
ft.Icon(ft.icons.AUTO_AWESOME, size=64, color=ft.colors.BLUE_400),
|
||||||
|
ft.Text(
|
||||||
|
"AI Action Plan",
|
||||||
|
size=24,
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
color=ft.colors.BLUE_400,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
"Select a PR or Issue and generate an AI action plan",
|
||||||
|
size=14,
|
||||||
|
color=ft.colors.GREY_600,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
),
|
||||||
|
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=10),
|
||||||
|
alignment=ft.alignment.center,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI Instructions input
|
||||||
|
ai_instructions = ft.TextField(
|
||||||
|
ref=self.ai_instructions_ref,
|
||||||
|
label="Additional Instructions (Optional)",
|
||||||
|
hint_text="Add any specific requirements or context for the AI...",
|
||||||
|
multiline=True,
|
||||||
|
min_lines=2,
|
||||||
|
max_lines=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Buttons row
|
||||||
|
buttons_row = ft.Row(
|
||||||
|
[
|
||||||
|
ft.ElevatedButton(
|
||||||
|
ref=self.generate_plan_button_ref,
|
||||||
|
text="Generate Plan",
|
||||||
|
icon=ft.icons.PSYCHOLOGY,
|
||||||
|
on_click=self._on_generate_plan_click,
|
||||||
|
disabled=True, # Enable when item is selected
|
||||||
|
),
|
||||||
|
ft.ElevatedButton(
|
||||||
|
ref=self.execute_plan_button_ref,
|
||||||
|
text="Execute Plan",
|
||||||
|
icon=ft.icons.PLAY_ARROW,
|
||||||
|
on_click=self._on_execute_plan_click,
|
||||||
|
disabled=True, # Enable when plan is generated
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
color=ft.colors.WHITE,
|
||||||
|
bgcolor=ft.colors.GREEN_700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Progress bar (indeterminate/animated when visible)
|
||||||
|
progress_bar = ft.ProgressBar(
|
||||||
|
ref=self.plan_progress_ref,
|
||||||
|
visible=False,
|
||||||
|
color=ft.colors.BLUE_400,
|
||||||
|
bgcolor=ft.colors.BLUE_GREY_800,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status text
|
||||||
|
status_text = ft.Text(
|
||||||
|
ref=self.plan_status_ref,
|
||||||
|
value="",
|
||||||
|
color=ft.colors.BLUE_400,
|
||||||
|
size=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main layout
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ai_instructions,
|
||||||
|
buttons_row,
|
||||||
|
progress_bar,
|
||||||
|
status_text,
|
||||||
|
ft.Divider(),
|
||||||
|
plan_display,
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
padding=20,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_generate_plan_click(self, e):
|
||||||
|
"""Generate AI action plan for current item"""
|
||||||
|
if not self.active_workflow_item:
|
||||||
|
self._show_snackbar("Please select a PR or Issue first", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run in thread to avoid blocking UI
|
||||||
|
import threading
|
||||||
|
thread = threading.Thread(target=self._generate_plan_async)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _generate_plan_async(self):
|
||||||
|
"""Generate plan asynchronously"""
|
||||||
|
try:
|
||||||
|
# Update UI - disable both buttons during generation
|
||||||
|
if self.generate_plan_button_ref.current:
|
||||||
|
self.generate_plan_button_ref.current.disabled = True
|
||||||
|
if self.execute_plan_button_ref.current:
|
||||||
|
self.execute_plan_button_ref.current.disabled = True
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = "🤖 Generating action plan..."
|
||||||
|
if self.plan_progress_ref.current:
|
||||||
|
self.plan_progress_ref.current.visible = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
# Create action planner
|
||||||
|
from .ai_action_planner import AIActionPlanner
|
||||||
|
planner = AIActionPlanner(self.ai_manager, self.logger, self.config_manager)
|
||||||
|
|
||||||
|
# Get custom instructions
|
||||||
|
custom_instructions = ""
|
||||||
|
if self.ai_instructions_ref.current:
|
||||||
|
custom_instructions = self.ai_instructions_ref.current.value or ""
|
||||||
|
|
||||||
|
# Generate plan
|
||||||
|
plan = planner.generate_plan(self.active_workflow_item, custom_instructions)
|
||||||
|
|
||||||
|
if plan:
|
||||||
|
self.current_action_plan = plan
|
||||||
|
self._display_action_plan(plan)
|
||||||
|
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = f"✅ Plan generated with {len(plan.steps)} steps"
|
||||||
|
if self.execute_plan_button_ref.current:
|
||||||
|
self.execute_plan_button_ref.current.disabled = False
|
||||||
|
else:
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = "❌ Failed to generate plan"
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
import traceback
|
||||||
|
self.logger.log(f"❌ Error generating plan: {str(ex)}")
|
||||||
|
self.logger.log(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = f"❌ Error: {str(ex)}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Re-enable Generate Plan button
|
||||||
|
if self.generate_plan_button_ref.current:
|
||||||
|
self.generate_plan_button_ref.current.disabled = False
|
||||||
|
# Only enable Execute Plan if we have a valid plan
|
||||||
|
if self.execute_plan_button_ref.current and self.current_action_plan:
|
||||||
|
self.execute_plan_button_ref.current.disabled = False
|
||||||
|
if self.plan_progress_ref.current:
|
||||||
|
self.plan_progress_ref.current.visible = False
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _display_action_plan(self, plan):
|
||||||
|
"""Display the generated action plan"""
|
||||||
|
if not self.plan_display_ref.current:
|
||||||
|
return
|
||||||
|
|
||||||
|
steps_widgets = []
|
||||||
|
|
||||||
|
# Plan header
|
||||||
|
steps_widgets.append(
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Column([
|
||||||
|
ft.Text(plan.title, size=18, weight=ft.FontWeight.BOLD),
|
||||||
|
ft.Text(
|
||||||
|
f"Context: {plan.context.get('item_type', 'unknown').upper()} #{plan.context.get('item_number', '?')}",
|
||||||
|
size=12,
|
||||||
|
color=ft.colors.GREY_600
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
padding=10,
|
||||||
|
bgcolor=ft.colors.BLUE_GREY_900,
|
||||||
|
border_radius=5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Individual steps
|
||||||
|
for step in plan.steps:
|
||||||
|
step_num = step['step_number']
|
||||||
|
description = step['description']
|
||||||
|
file_path = step.get('file_path', 'N/A')
|
||||||
|
action_type = step.get('action_type', 'unknown')
|
||||||
|
status = step.get('status', 'pending')
|
||||||
|
|
||||||
|
# Status icon and color based on step status
|
||||||
|
if status == 'completed':
|
||||||
|
icon = ft.icons.CHECK_CIRCLE
|
||||||
|
icon_color = ft.colors.GREEN
|
||||||
|
bg_color = None # No special background
|
||||||
|
elif status == 'failed':
|
||||||
|
icon = ft.icons.ERROR
|
||||||
|
icon_color = ft.colors.RED
|
||||||
|
bg_color = None
|
||||||
|
elif status == 'in_progress':
|
||||||
|
icon = ft.icons.TIMELAPSE # Animated-looking icon
|
||||||
|
icon_color = ft.colors.BLUE_400
|
||||||
|
bg_color = ft.colors.BLUE_GREY_800 # Highlight in-progress steps
|
||||||
|
else: # pending
|
||||||
|
icon = ft.icons.RADIO_BUTTON_UNCHECKED
|
||||||
|
icon_color = ft.colors.GREY
|
||||||
|
bg_color = None
|
||||||
|
|
||||||
|
step_widget = ft.Container(
|
||||||
|
content=ft.Row([
|
||||||
|
ft.Icon(icon, color=icon_color, size=20),
|
||||||
|
ft.Column([
|
||||||
|
ft.Text(f"Step {step_num}: {description}", weight=ft.FontWeight.BOLD, size=14),
|
||||||
|
ft.Text(f"Action: {action_type}", size=11, color=ft.colors.BLUE_400),
|
||||||
|
ft.Text(f"File: {file_path}", size=11, color=ft.colors.GREY_600) if file_path != 'N/A' else ft.Container(),
|
||||||
|
], spacing=2, expand=True),
|
||||||
|
], spacing=10),
|
||||||
|
padding=10,
|
||||||
|
border=ft.border.all(1, ft.colors.GREY_700),
|
||||||
|
border_radius=5,
|
||||||
|
bgcolor=bg_color, # Highlight in-progress steps
|
||||||
|
)
|
||||||
|
steps_widgets.append(step_widget)
|
||||||
|
|
||||||
|
# Update display
|
||||||
|
self.plan_display_ref.current.controls = steps_widgets
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _on_execute_plan_click(self, e):
|
||||||
|
"""Execute the current action plan"""
|
||||||
|
if not self.current_action_plan:
|
||||||
|
self._show_snackbar("No plan to execute", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run in thread
|
||||||
|
import threading
|
||||||
|
thread = threading.Thread(target=self._execute_plan_async)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _execute_plan_async(self):
|
||||||
|
"""Execute plan asynchronously"""
|
||||||
|
try:
|
||||||
|
self.logger.log("🔧 Starting _execute_plan_async...")
|
||||||
|
|
||||||
|
# Update UI - disable both buttons during execution
|
||||||
|
if self.execute_plan_button_ref.current:
|
||||||
|
self.execute_plan_button_ref.current.disabled = True
|
||||||
|
if self.generate_plan_button_ref.current:
|
||||||
|
self.generate_plan_button_ref.current.disabled = True
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = "▶️ Executing plan..."
|
||||||
|
if self.plan_progress_ref.current:
|
||||||
|
self.plan_progress_ref.current.visible = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
# Get local repo path
|
||||||
|
config = self.config_manager.get_config()
|
||||||
|
local_repo_path = config.get('LOCAL_REPO_PATH', '')
|
||||||
|
self.logger.log(f"🔧 Local repo path: {local_repo_path}")
|
||||||
|
|
||||||
|
if not local_repo_path:
|
||||||
|
self.logger.log("❌ LOCAL_REPO_PATH is not set")
|
||||||
|
self._show_snackbar("Please set LOCAL_REPO_PATH in settings", error=True)
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = "❌ LOCAL_REPO_PATH not set in settings"
|
||||||
|
self.page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create action planner
|
||||||
|
from .ai_action_planner import AIActionPlanner
|
||||||
|
planner = AIActionPlanner(self.ai_manager, self.logger, self.config_manager)
|
||||||
|
self.logger.log(f"🔧 Created AIActionPlanner, about to execute plan with {len(self.current_action_plan.steps)} steps")
|
||||||
|
|
||||||
|
# Execute with progress callback
|
||||||
|
def progress_callback(current, total, message):
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = f"▶️ {message} ({current}/{total})"
|
||||||
|
# Update the plan display in real-time to show progress
|
||||||
|
if self.current_action_plan:
|
||||||
|
self._display_action_plan(self.current_action_plan)
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
# Execute with logging callback for thought process
|
||||||
|
def log_callback(message):
|
||||||
|
# Log to the processing log
|
||||||
|
self.logger.log(message)
|
||||||
|
# Also show in status if it's important
|
||||||
|
if any(keyword in message for keyword in ["🤖", "✅", "❌", "📝", "🔍"]):
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = message
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
result = planner.execute_plan(
|
||||||
|
self.current_action_plan,
|
||||||
|
local_repo_path,
|
||||||
|
progress_callback,
|
||||||
|
log_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.log(f"🔧 execute_plan returned: {result}")
|
||||||
|
|
||||||
|
# Update display
|
||||||
|
self._display_action_plan(self.current_action_plan)
|
||||||
|
|
||||||
|
# Show completion dialog
|
||||||
|
if result['success']:
|
||||||
|
self._show_completion_dialog(result)
|
||||||
|
else:
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = f"⚠️ Plan completed with errors: {result['failed']}/{result['total']} steps failed"
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
import traceback
|
||||||
|
self.logger.log(f"❌ Error executing plan: {str(ex)}")
|
||||||
|
self.logger.log(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
if self.plan_status_ref.current:
|
||||||
|
self.plan_status_ref.current.value = f"❌ Error: {str(ex)}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Re-enable both buttons after execution
|
||||||
|
if self.execute_plan_button_ref.current:
|
||||||
|
self.execute_plan_button_ref.current.disabled = False
|
||||||
|
if self.generate_plan_button_ref.current:
|
||||||
|
self.generate_plan_button_ref.current.disabled = False
|
||||||
|
if self.plan_progress_ref.current:
|
||||||
|
self.plan_progress_ref.current.visible = False
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _show_completion_dialog(self, result):
|
||||||
|
"""Show dialog after plan execution with options to push/create PR"""
|
||||||
|
context = self.current_action_plan.context
|
||||||
|
item_type = context.get('item_type', 'unknown')
|
||||||
|
item_number = context.get('item_number', '?')
|
||||||
|
|
||||||
|
def close_dialog(e):
|
||||||
|
dialog.open = False
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def push_changes(e):
|
||||||
|
dialog.open = False
|
||||||
|
self.page.update()
|
||||||
|
self._push_changes_async()
|
||||||
|
|
||||||
|
def create_pr(e):
|
||||||
|
dialog.open = False
|
||||||
|
self.page.update()
|
||||||
|
self._create_pr_from_plan_async()
|
||||||
|
|
||||||
|
dialog = ft.AlertDialog(
|
||||||
|
title=ft.Text("✅ Plan Execution Complete!"),
|
||||||
|
content=ft.Column([
|
||||||
|
ft.Text(f"Successfully completed {result['completed']}/{result['total']} steps"),
|
||||||
|
ft.Divider(),
|
||||||
|
ft.Text("What would you like to do next?", weight=ft.FontWeight.BOLD),
|
||||||
|
ft.Text(f"• Push changes to the {item_type} branch", size=12),
|
||||||
|
ft.Text(f"• Create/Update PR for these changes", size=12),
|
||||||
|
ft.Text(f"• Review changes manually", size=12),
|
||||||
|
], tight=True, spacing=10),
|
||||||
|
actions=[
|
||||||
|
ft.TextButton("Review Later", on_click=close_dialog),
|
||||||
|
ft.ElevatedButton(
|
||||||
|
"Push Changes",
|
||||||
|
icon=ft.icons.CLOUD_UPLOAD,
|
||||||
|
on_click=push_changes,
|
||||||
|
),
|
||||||
|
ft.ElevatedButton(
|
||||||
|
"Create/Update PR",
|
||||||
|
icon=ft.icons.MERGE_TYPE,
|
||||||
|
on_click=create_pr,
|
||||||
|
style=ft.ButtonStyle(bgcolor=ft.colors.GREEN_700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
actions_alignment=ft.MainAxisAlignment.END,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.page.dialog = dialog
|
||||||
|
dialog.open = True
|
||||||
|
self.page.update()
|
||||||
|
|
||||||
|
def _push_changes_async(self):
|
||||||
|
"""Push changes to git repository"""
|
||||||
|
# This will be implemented to push changes
|
||||||
|
self.logger.log("🚀 Pushing changes to repository...")
|
||||||
|
# TODO: Implement git push logic
|
||||||
|
self._show_snackbar("Push functionality coming soon!")
|
||||||
|
|
||||||
|
def _create_pr_from_plan_async(self):
|
||||||
|
"""Create or update PR from executed plan"""
|
||||||
|
# This will be implemented to create/update PR
|
||||||
|
self.logger.log("📝 Creating/updating PR...")
|
||||||
|
# TODO: Implement PR creation logic
|
||||||
|
self._show_snackbar("PR creation functionality coming soon!")
|
||||||
|
|
||||||
|
|
||||||
class Logger:
|
class Logger:
|
||||||
"""Logger class for Flet"""
|
"""Logger class for Flet"""
|
||||||
|
|||||||
@@ -771,6 +771,7 @@ class SettingsDialog:
|
|||||||
if not path_str:
|
if not path_str:
|
||||||
path_str = str(Path.home() / "Downloads" / "github_repos")
|
path_str = str(Path.home() / "Downloads" / "github_repos")
|
||||||
|
|
||||||
|
print(f"🔍 Scanning for repos in: {path_str}")
|
||||||
base_path = Path(path_str)
|
base_path = Path(path_str)
|
||||||
|
|
||||||
if not base_path.exists():
|
if not base_path.exists():
|
||||||
@@ -783,26 +784,70 @@ class SettingsDialog:
|
|||||||
# Scan for git repositories
|
# Scan for git repositories
|
||||||
repos = []
|
repos = []
|
||||||
try:
|
try:
|
||||||
for owner_dir in base_path.iterdir():
|
# First, check if repos are directly in the base path (flat structure)
|
||||||
if not owner_dir.is_dir():
|
for item in base_path.iterdir():
|
||||||
|
if not item.is_dir():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for repo_dir in owner_dir.iterdir():
|
git_dir = item / ".git"
|
||||||
|
if git_dir.exists():
|
||||||
|
# This is a git repo directly in the base path
|
||||||
|
# Try to get the remote origin to get owner/repo format
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'config', '--get', 'remote.origin.url'],
|
||||||
|
cwd=str(item),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
url = result.stdout.strip()
|
||||||
|
# Parse GitHub URL to get owner/repo
|
||||||
|
if 'github.com' in url:
|
||||||
|
# Handle both HTTPS and SSH URLs
|
||||||
|
if url.startswith('https://'):
|
||||||
|
# https://github.com/owner/repo.git
|
||||||
|
parts = url.replace('https://github.com/', '').replace('.git', '').split('/')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
repo_name = f"{parts[0]}/{parts[1]}"
|
||||||
|
repos.append(repo_name)
|
||||||
|
continue
|
||||||
|
elif url.startswith('git@'):
|
||||||
|
# git@github.com:owner/repo.git
|
||||||
|
parts = url.replace('git@github.com:', '').replace('.git', '').split('/')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
repo_name = f"{parts[0]}/{parts[1]}"
|
||||||
|
repos.append(repo_name)
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: use directory name
|
||||||
|
repos.append(f"local/{item.name}")
|
||||||
|
else:
|
||||||
|
# Check if this is an owner directory with repos inside (nested structure)
|
||||||
|
for repo_dir in item.iterdir():
|
||||||
if not repo_dir.is_dir():
|
if not repo_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
git_dir = repo_dir / ".git"
|
git_dir = repo_dir / ".git"
|
||||||
if git_dir.exists():
|
if git_dir.exists():
|
||||||
repo_name = f"{owner_dir.name}/{repo_dir.name}"
|
repo_name = f"{item.name}/{repo_dir.name}"
|
||||||
repos.append(repo_name)
|
repos.append(repo_name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error scanning repos: {e}")
|
print(f"Error scanning repos: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
# Update dropdown
|
# Update dropdown
|
||||||
if self.detected_repos_dropdown_ref.current:
|
if self.detected_repos_dropdown_ref.current:
|
||||||
if repos:
|
if repos:
|
||||||
repos.sort()
|
repos.sort()
|
||||||
|
print(f"✅ Found {len(repos)} repo(s): {', '.join(repos)}")
|
||||||
self.detected_repos_dropdown_ref.current.options = [
|
self.detected_repos_dropdown_ref.current.options = [
|
||||||
ft.dropdown.Option(repo) for repo in repos
|
ft.dropdown.Option(repo) for repo in repos
|
||||||
]
|
]
|
||||||
@@ -811,6 +856,7 @@ class SettingsDialog:
|
|||||||
else:
|
else:
|
||||||
self.detected_repos_dropdown_ref.current.value = f'{len(repos)} repo(s) found - select one'
|
self.detected_repos_dropdown_ref.current.value = f'{len(repos)} repo(s) found - select one'
|
||||||
else:
|
else:
|
||||||
|
print(f"❌ No git repositories found in {path_str}")
|
||||||
self.detected_repos_dropdown_ref.current.value = 'No git repositories found'
|
self.detected_repos_dropdown_ref.current.value = 'No git repositories found'
|
||||||
self.detected_repos_dropdown_ref.current.options = []
|
self.detected_repos_dropdown_ref.current.options = []
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ GitHub Pulse
|
|||||||
Main application entry point
|
Main application entry point
|
||||||
|
|
||||||
This application provides GitHub automation workflows with AI assistance.
|
This application provides GitHub automation workflows with AI assistance.
|
||||||
|
|
||||||
|
Note: You may see a Flutter engine warning when closing the app:
|
||||||
|
"embedder.cc (2519): 'FlutterEngineRemoveView' returned 'kInvalidArguments'"
|
||||||
|
This is a harmless known issue with Flet/Flutter and can be safely ignored.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
Reference in New Issue
Block a user