diff --git a/src/app_components/__init__.py b/src/app_components/__init__.py deleted file mode 100644 index 5311d86..0000000 --- a/src/app_components/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -GitHub Pulse - Application Components -Modular components for the application -""" - -import sys -import os - -# Version info -__version__ = "0.0.1" -__author__ = "TySP-Dev" -__app_name__ = "GitHub Pulse" - -# Determine if running in production build -IS_PRODUCTION = getattr(sys, 'frozen', False) - -# Get the application directory -if IS_PRODUCTION: - # In production build, get the executable directory - APP_DIR = os.path.dirname(sys.executable) -else: - # In development, get the source directory - APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -# Export main classes for easier imports -from .config_manager import ConfigManager -from .ai_manager import AIManager -from .github_api import GitHubAPI -from .settings_dialog import SettingsDialog -from .main_gui import MainGUI -from .utils import Logger, PRNumberManager, ContentBuilders -from .workflow import WorkflowManager, WorkflowItem, GitHubRepoFetcher -from .ai_action_planner import AIActionPlanner, ActionPlan - -__all__ = [ - 'ConfigManager', - 'AIManager', - 'GitHubAPI', - 'SettingsDialog', - 'MainGUI', - 'Logger', - 'PRNumberManager', - 'ContentBuilders', - 'WorkflowManager', - 'WorkflowItem', - 'GitHubRepoFetcher', - 'AIActionPlanner', - 'ActionPlan', - '__version__', - '__author__', - '__app_name__', - 'IS_PRODUCTION', - 'APP_DIR' -] diff --git a/src/app_components/ai_action_planner.py b/src/app_components/ai_action_planner.py deleted file mode 100644 index b42fdc1..0000000 --- a/src/app_components/ai_action_planner.py +++ /dev/null @@ -1,617 +0,0 @@ -""" -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)}'} diff --git a/src/app_components/ai_manager.py b/src/app_components/ai_manager.py deleted file mode 100644 index 0a24d30..0000000 --- a/src/app_components/ai_manager.py +++ /dev/null @@ -1,3430 +0,0 @@ -""" -AI Manager -Handles AI module availability checking, installation, and provider management -Includes AI provider implementations (Claude, ChatGPT) and git operations -""" - -import os -import shutil -import subprocess -import sys -import tempfile -import time -from abc import ABC, abstractmethod -from pathlib import Path -from typing import List, Tuple, Optional - - -class Logger: - """Simple logger interface""" - def __init__(self, log_func): - self.log = log_func - - -class AIProvider(ABC): - """Base class for AI providers""" - - def __init__(self, api_key: str, logger: Logger): - self.api_key = api_key - self.logger = logger - - @abstractmethod - def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: - """ - Use AI to make a change in the file content. - - Args: - file_content: Current content of the file - old_text: Text to find and replace - new_text: New text to replace with - file_path: Path to the file (for context) - custom_instructions: Optional custom instructions from user - - Returns: - Updated file content, or None if AI couldn't make the change - """ - pass - - -class ClaudeProvider(AIProvider): - """Claude AI provider using Anthropic API""" - - 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 - - Args: - file_content: Full file content - old_text: Reference text (what user is talking about - may not be exact) - new_text: Suggested changes (what user wants to see) - file_path: Path to the file being modified - custom_instructions: Optional custom instructions from user - """ - - # Step 1: Try direct string replacement if reference text is exact match - if old_text and old_text.strip() in file_content: - self.logger.log("āœ… Making direct string replacement (reference text found exactly)") - updated_content = file_content.replace(old_text.strip(), new_text.strip()) - if updated_content != file_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - self.logger.log(f"āœ… Direct replacement successful ({changed_lines} lines changed)") - return updated_content - - # Step 2: Use AI to generate full document with targeted changes - self.logger.log("šŸ“ Using AI to modify the document...") - return self._generate_updated_document(file_content, old_text, new_text, file_path, custom_instructions) - - def _generate_updated_document(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: - """Generate updated document content using Claude""" - - try: - import anthropic - client = anthropic.Anthropic(api_key=self.api_key) - - # Build custom instructions text - if custom_instructions and custom_instructions.strip(): - custom_instructions_text = f""" -**Additional Custom Instructions:** -{custom_instructions.strip()} - -""" - else: - custom_instructions_text = "" - - # Handle case where new_text is empty or just guidance - if new_text and new_text.strip() and not new_text.strip().lower().startswith(' [!IMPORTANT] -> OUTPUT REQUIREMENTS: -> - Return ONLY the complete file content - no explanatory text, dialog, or commentary -> - Do NOT add any text before or after the file content -> - Do NOT wrap output in markdown code blocks (```), just return the raw content -> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...] -> - Every single line of the original document must be present in your response -> - Preserve all markdown formatting, links, and code blocks exactly -> - Please ensure the changes align with Microsoft documentation standards -> - Only make changes that fulfill the specified request - -{custom_instructions_text} - -**Current File Content:** -``` -{file_content} -``` - -{guidance_text} - -Return the complete updated file content now (NO explanatory text):""" - - message = client.messages.create( - model="claude-3-5-haiku-20241022", - max_tokens=4096, - temperature=0.1, - messages=[{"role": "user", "content": prompt}] - ) - - updated_content = message.content[0].text.strip() - - # Basic validation - ensure content was actually changed - if updated_content and updated_content != file_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - self.logger.log(f"āœ… Claude document update successful ({changed_lines} lines affected)") - return updated_content - else: - self.logger.log("āš ļø No changes detected in AI response") - return None - - except Exception as e: - self.logger.log(f"āŒ Error generating updated document with Claude: {str(e)}") - return None - - def _generate_with_context_window_claude(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Use context window approach with Claude - AI only sees/modifies a small section - - This physically prevents AI from rewriting entire file by only giving it - the relevant section to work with. - """ - try: - import difflib - import anthropic - - # Step 1: Find where the reference text is located - lines = file_content.split('\n') - ref_lines = old_text.split('\n') if old_text else [] - - # Find best matching location for reference text - start_line = 0 - if ref_lines: - matcher = difflib.SequenceMatcher(None, ref_lines, lines) - match = matcher.find_longest_match(0, len(ref_lines), 0, len(lines)) - if match.size > 0: - start_line = match.b - self.logger.log(f"šŸ“ Found reference area at line {start_line + 1}") - else: - self.logger.log("šŸ“ Reference text not found, using beginning of file") - - # Step 2: Extract context window (30 lines before, 30 lines after) - window_before = 30 - window_after = 30 - - window_start = max(0, start_line - window_before) - window_end = min(len(lines), start_line + len(ref_lines) + window_after) - - context_window = lines[window_start:window_end] - self.logger.log(f"šŸ“„ Context window: lines {window_start + 1} to {window_end} ({len(context_window)} lines)") - self.logger.log(f" (AI can only modify this section, rest of file is protected)") - - # Step 3: Have AI modify only the context window - context_text = '\n'.join(context_window) - - client = anthropic.Anthropic(api_key=self.api_key) - - prompt = f"""You are helping modify a small section of a documentation file. You can ONLY modify the section provided below. - -File: {file_path} -Section location: Lines {window_start + 1} to {window_end} - -REFERENCE TEXT (what user is referring to): -{old_text} - -SUGGESTED CHANGES (what user wants): -{new_text} - -SECTION TO MODIFY: -``` -{context_text} -``` - -INSTRUCTIONS: -1. Understand the user's INTENT from the reference and suggestions: - - "add/include/incorporate a section" = Add a COMPLETE NEW SECTION with heading and full content - - "update/modify/change X" = Modify existing text X intelligently - - "fix/correct" = Make specific correction only - - Be generous with new content when asked to add something - -2. For ADDING content (sections, paragraphs, examples): - - Create complete, well-written content (not just stubs or brief additions) - - Add proper markdown headers (## Best Practices, ### Example, etc.) - - Place it logically (end of section, before ## Related content, etc.) - - Match the document's writing style and tone - -3. For MODIFYING content: - - Change only what's requested - - Leave everything else exactly as-is - -4. Return the ENTIRE section (all {len(context_window)} lines) with your changes -5. No explanations - just the modified section - -OUTPUT THE COMPLETE MODIFIED SECTION:""" - - message = client.messages.create( - model="claude-3-5-haiku-20241022", - max_tokens=4096, - temperature=0.1, - messages=[{"role": "user", "content": prompt}] - ) - - modified_window = message.content[0].text.strip() - - # Clean up code blocks if AI wrapped it - if modified_window.startswith('```'): - modified_window = '\n'.join(modified_window.split('\n')[1:-1]) - - # Step 4: Replace the context window in the full file - modified_lines = modified_window.split('\n') - result_lines = lines[:window_start] + modified_lines + lines[window_end:] - updated_content = '\n'.join(result_lines) - - # Verify change is minimal - diff = list(difflib.unified_diff(lines, result_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - self.logger.log(f"āœ… Context window approach successful ({changed_lines} lines changed)") - - # Ensure we actually made changes - if updated_content == file_content: - self.logger.log("āš ļø No changes detected, falling back to full-document approach") - return self._generate_updated_document(file_content, old_text, new_text, file_path) - - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error with context window approach: {str(e)}") - self.logger.log("āš ļø Falling back to full-document approach") - return self._generate_updated_document(file_content, old_text, new_text, file_path) - - def _validate_diff_patch(self, diff_patch: str, original_content: str, old_text: str, new_text: str) -> bool: - """Validate that the AI-generated diff is safe and appropriate""" - try: - # Check for common problems - lines = diff_patch.split('\n') - - # Problem 0: Check for proper diff structure - has_hunk_header = any(line.startswith('@@') for line in lines) - if not has_hunk_header: - self.logger.log("āŒ Invalid diff: Missing @@ hunk headers") - return False - - # Problem 1: Check for duplicate +++ lines - plus_count = sum(1 for line in lines if line.startswith('+++')) - if plus_count > 1: - self.logger.log("āŒ Invalid diff: Multiple +++ lines detected") - return False - - # Problem 2: Check for removal of metadata (title, author, etc.) - for line in lines: - if line.startswith('-') and not line.startswith('---'): - removed_content = line[1:].strip() - # Check if removing metadata - if any(keyword in removed_content.lower() for keyword in ['title:', 'author:', 'description:', 'ms.author:', 'ms.date:']): - self.logger.log(f"āŒ Invalid diff: Attempting to remove metadata: {removed_content}") - return False - - # Problem 3: Check if diff is too large (indicates rewrite) - removed_lines = len([line for line in lines if line.startswith('-') and not line.startswith('---')]) - added_lines = len([line for line in lines if line.startswith('+') and not line.startswith('+++')]) - - if removed_lines > 10: # Too many removals for an additive change - self.logger.log(f"āŒ Invalid diff: Too many removals ({removed_lines} lines)") - return False - - return True - - except Exception as e: - self.logger.log(f"āŒ Error validating diff: {str(e)}") - return False - - def _create_safe_diff(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Create a safer, simpler diff that just adds content without removing anything""" - try: - # Strategy: Find the best location to add the new content and insert it there - lines = file_content.split('\n') - - # Look for common insertion points for adding sections - insertion_point = self._find_safe_insertion_point(lines, old_text, new_text) - - if insertion_point is None: - self.logger.log("āš ļø Could not find safe insertion point") - return None - - # Insert the new content at the found location - new_lines = lines[:insertion_point] + [new_text.strip(), ''] + lines[insertion_point:] - updated_content = '\n'.join(new_lines) - - self.logger.log(f"āœ… Created safe diff - inserting content at line {insertion_point}") - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error creating safe diff: {str(e)}") - return None - - def _find_safe_insertion_point(self, lines: list, old_text: str, new_text: str) -> Optional[int]: - """Find the best place to insert new content safely""" - try: - # Look for section headers to insert after - for i, line in enumerate(lines): - # If the old_text contains context about where to insert - if old_text and old_text.lower().strip() in line.lower(): - # Insert after this line - return i + 1 - - # Look for pattern where we should insert a new section - # Insert before conclusion, examples, or other sections - if line.strip().startswith('##') and any(keyword in line.lower() for keyword in ['example', 'conclusion', 'summary', 'next steps']): - return i - - # If no specific location found, insert before the last section - for i in range(len(lines) - 1, -1, -1): - if lines[i].strip().startswith('##'): - return i - - # Last resort: insert at 80% through the document - return int(len(lines) * 0.8) - - except Exception: - return None - - def _apply_diff_patch(self, original_content: str, diff_patch: str, file_path: str) -> Optional[str]: - """Apply a unified diff patch to the original content""" - try: - import tempfile - import subprocess - import os - - # Create temporary files - with tempfile.TemporaryDirectory() as temp_dir: - # Write original content to temp file - original_file = os.path.join(temp_dir, "original.txt") - with open(original_file, 'w', encoding='utf-8') as f: - f.write(original_content) - - # Write diff patch to temp file - patch_file = os.path.join(temp_dir, "changes.patch") - with open(patch_file, 'w', encoding='utf-8') as f: - f.write(diff_patch) - - # Apply patch using git apply (more reliable than patch command) - try: - # First try git apply - subprocess.run(['git', 'apply', '--verbose', patch_file], - cwd=temp_dir, check=True, capture_output=True, text=True) - - # Read the result - with open(original_file, 'r', encoding='utf-8') as f: - return f.read() - - except subprocess.CalledProcessError: - # Fallback to manual patch application - self.logger.log("šŸ“ Git apply failed, trying manual diff application...") - return self._manual_diff_apply(original_content, diff_patch) - - except Exception as e: - self.logger.log(f"āš ļø Patch application failed: {str(e)}") - return self._manual_diff_apply(original_content, diff_patch) - - def _manual_diff_apply(self, original_content: str, diff_patch: str) -> Optional[str]: - """Manually apply a diff patch when git apply fails""" - try: - # Detect original line ending style - has_crlf = '\r\n' in original_content - - original_lines = original_content.split('\n') - result_lines = original_lines.copy() - - # Parse the diff patch - diff_lines = diff_patch.split('\n') - current_original_line = 0 - - i = 0 - while i < len(diff_lines): - line = diff_lines[i] - - # Look for @@ headers - if line.startswith('@@'): - # Extract line numbers: @@ -start,count +start,count @@ - parts = line.split() - if len(parts) >= 3: - old_info = parts[1][1:] # Remove the - - if ',' in old_info: - start_line = int(old_info.split(',')[0]) - 1 # Convert to 0-based - else: - start_line = int(old_info) - 1 - - current_original_line = start_line - i += 1 - continue - - # Skip diff headers (must check before processing -/+ lines) - if line.startswith('---') or line.startswith('+++'): - i += 1 - continue - - # Process diff lines - if line.startswith('-'): - # Remove line - if current_original_line < len(result_lines): - del result_lines[current_original_line] - elif line.startswith('+'): - # Add line - new_line = line[1:] # Remove the + - # If original had CRLF and this line doesn't have \r, add it - if has_crlf and not new_line.endswith('\r'): - new_line = new_line + '\r' - result_lines.insert(current_original_line, new_line) - current_original_line += 1 - elif line.startswith(' '): - # Context line - advance - current_original_line += 1 - - i += 1 - - return '\n'.join(result_lines) - - except Exception as e: - self.logger.log(f"āŒ Manual diff application failed: {str(e)}") - return None - - def _detect_change_type(self, old_text: str, new_text: str, file_path: str) -> str: - """Detect the type of change requested""" - old_lower = old_text.lower() - new_lower = new_text.lower() - - # Additive indicators - additive_keywords = ['add', 'include', 'incorporate', 'insert', 'create section', 'new section', 'best practices'] - if any(keyword in old_lower or keyword in new_lower for keyword in additive_keywords): - return "ADDITIVE" - - # Corrective indicators - corrective_keywords = ['correct', 'fix', 'grammar', 'spelling', 'typo', 'misspell', 'wrong', 'error'] - if any(keyword in old_lower or keyword in new_lower for keyword in corrective_keywords): - return "CORRECTIVE" - - # If new text is much longer than old text, likely additive - if len(new_text.strip()) > len(old_text.strip()) * 2: - return "ADDITIVE" - - # If similar length, likely corrective - if abs(len(new_text.strip()) - len(old_text.strip())) < 50: - return "CORRECTIVE" - - return "GENERAL" - - def _handle_additive_change(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle additive changes by generating content and inserting it""" - self.logger.log("šŸ”Ø Handling additive change - generating new content...") - - try: - import anthropic - client = anthropic.Anthropic(api_key=self.api_key) - - prompt = f"""**Instructions:** - -Task: Add new content to the documentation file as requested. - -Steps to complete: - -1. Generate ONLY the new content that should be added to the documentation file -2. Maintain proper formatting, indentation, and markdown structure -3. Make content standalone - don't reference existing content in the file -4. Use Microsoft documentation standards - -> [!IMPORTANT] -> Only create the new content - do not rewrite or modify existing content. -> Preserve markdown formatting, links, and code blocks as appropriate. -> Please ensure the changes align with Microsoft documentation standards. - -File: {file_path} -Request: {old_text} -Content to add: {new_text} - -Generate only the new content that should be added:""" - - message = client.messages.create( - model="claude-3-5-haiku-20241022", - max_tokens=2048, - temperature=0.1, - messages=[{"role": "user", "content": prompt}] - ) - - new_content = message.content[0].text.strip() - - # Find best insertion point in the file - insertion_point = self._find_insertion_point(file_content, old_text, file_path) - - # Insert the new content - lines = file_content.split('\n') - lines.insert(insertion_point, '\n' + new_content + '\n') - updated_content = '\n'.join(lines) - - # Count actual changes - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+')]) - - self.logger.log(f"āœ… Added new content ({changed_lines} lines added)") - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error in additive change: {str(e)}") - return None - - def _handle_corrective_change(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle corrective changes by finding and fixing specific issues""" - self.logger.log("šŸ” Handling corrective change - finding specific issues...") - - try: - import anthropic - client = anthropic.Anthropic(api_key=self.api_key) - - prompt = f"""**Instructions:** - -Task: Find and fix a specific issue in the documentation file. - -Steps to complete: - -1. Locate the exact text that needs to be corrected in the file -2. Provide the precise replacement text -3. Make minimal changes - fix only what needs to be fixed -4. Maintain existing formatting and structure - -> [!IMPORTANT] -> Only make the specified correction - do not make additional changes. -> Preserve all markdown formatting, links, and code blocks. -> Please ensure the changes align with Microsoft documentation standards. - -Issue: {old_text} -Fix: {new_text} -File: {file_path} - -Return your response in this format: -OLD: [exact text to find] -NEW: [exact replacement text] - -Be very specific - find the minimal text that needs changing. For example: -- If fixing "Microsft" → return OLD: Microsft, NEW: Microsoft -- If fixing grammar → return OLD: [the incorrect phrase], NEW: [corrected phrase] - -Find the exact text to correct:""" - - message = client.messages.create( - model="claude-3-5-haiku-20241022", - max_tokens=1024, - temperature=0.1, - messages=[{"role": "user", "content": f"{prompt}\n\nFile content to search:\n{file_content}"}] - ) - - response = message.content[0].text.strip() - - # Parse the response to extract OLD and NEW - old_match = None - new_match = None - - for line in response.split('\n'): - if line.startswith('OLD:'): - old_match = line[4:].strip() - elif line.startswith('NEW:'): - new_match = line[4:].strip() - - if old_match and new_match and old_match in file_content: - updated_content = file_content.replace(old_match, new_match) - # Count changes - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - self.logger.log(f"āœ… Corrective change successful ({changed_lines} lines affected)") - return updated_content - else: - self.logger.log(f"āš ļø Could not find exact text to correct") - return None - - except Exception as e: - self.logger.log(f"āŒ Error in corrective change: {str(e)}") - return None - - def _find_insertion_point(self, file_content: str, context: str, file_path: str) -> int: - """Find the best place to insert new content""" - lines = file_content.split('\n') - - # For markdown files, try to find a good section to add after - if file_path.endswith('.md'): - # Look for existing sections - for i, line in enumerate(lines): - if line.startswith('#') and i < len(lines) - 1: - # Insert after this section - continue - - # If no good sections found, add at the end - return len(lines) - - # For other files, add at the end - return len(lines) - - def _handle_general_change(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle general changes with enhanced targeting""" - self.logger.log("šŸŽÆ Handling general change with enhanced targeting...") - - max_retries = 3 - base_delay = 2 - - for attempt in range(max_retries): - try: - import anthropic - client = anthropic.Anthropic(api_key=self.api_key) - - prompt = f"""**Instructions:** - -Task: Update the documentation file with the specific change requested. - -Steps to complete: - -1. Locate the specific section that needs changing in the file -2. Make ONLY the requested change -3. Maintain the existing formatting, indentation, and markdown structure -4. Preserve everything else exactly as-is -5. Return the complete updated file - -> [!IMPORTANT] -> Only make the specified change - do not rewrite or reorganize content. -> Preserve all markdown formatting, links, and code blocks. -> Please ensure the changes align with Microsoft documentation standards. -> Make the SMALLEST possible change. - -File: {file_path} -Change needed: {old_text} -New content: {new_text} - -Current file content: -{file_content}""" - - message = client.messages.create( - model="claude-3-5-haiku-20241022", - max_tokens=4096, - temperature=0.1, - messages=[{"role": "user", "content": prompt}] - ) - - updated_content = message.content[0].text - - if new_text.strip() in updated_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - if changed_lines > 30: - self.logger.log(f"āš ļø Change affected {changed_lines} lines - may be too broad") - else: - self.logger.log(f"āœ… General change successful ({changed_lines} lines affected)") - - return updated_content - else: - self.logger.log("āš ļø New text not found in result") - return None - - except Exception as e: - if attempt < max_retries - 1: - delay = base_delay * (2 ** attempt) - self.logger.log(f"āš ļø Retry {attempt + 1}/{max_retries} after {delay}s...") - time.sleep(delay) - continue - else: - self.logger.log(f"āŒ Error in general change: {str(e)}") - return None - - return None - - -class ChatGPTProvider(AIProvider): - """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]: - """Make smart, targeted changes based on reference text and suggestions - - Args: - file_content: Full file content - old_text: Reference text (what user is talking about - may not be exact) - new_text: Suggested changes (what user wants to see) - file_path: Path to file being modified - custom_instructions: Optional custom instructions from user - """ - - # Step 1: Try direct string replacement if reference text is exact match - if old_text and old_text.strip() in file_content: - self.logger.log("āœ… Making direct string replacement (reference text found exactly)") - updated_content = file_content.replace(old_text.strip(), new_text.strip()) - if updated_content != file_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - self.logger.log(f"āœ… Direct replacement successful ({changed_lines} lines changed)") - return updated_content - - # Step 2: Use AI to generate full document with targeted changes - self.logger.log("šŸ“ Using AI to modify the document...") - return self._generate_updated_document_chatgpt(file_content, old_text, new_text, file_path, custom_instructions) - - def _generate_updated_document_chatgpt(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: - """Generate updated document content using ChatGPT""" - - try: - # Use the client initialized in __init__ - client = self.client - - # Build custom instructions text - if custom_instructions and custom_instructions.strip(): - custom_instructions_text = f""" - -**Additional Custom Instructions:** -{custom_instructions.strip()} - -""" - else: - custom_instructions_text = "" - - # Handle blank new_text field with dynamic prompt - if not new_text or not new_text.strip(): - # General improvement request when new text is blank - prompt = f"""**Instructions:** - -Task: Review and improve the documentation file based on the reference context provided. - -Steps to complete: - -1. Review the current file content below -2. Look at the reference context: "{old_text}" -3. Improve the relevant sections based on Microsoft documentation standards -4. Maintain the existing formatting, indentation, and markdown structure -5. Return the complete updated file content - -> [!IMPORTANT] -> OUTPUT REQUIREMENTS: -> - Return ONLY the complete file content - no explanatory text, dialog, or commentary -> - Do NOT add any text before or after the file content -> - Do NOT wrap output in markdown code blocks (```), just return the raw content -> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...] -> - Every single line of the original document must be present in your response -> - Focus on areas related to: {old_text} -> - Preserve all markdown formatting, links, and code blocks exactly -> - Please ensure improvements align with Microsoft documentation standards -> - Only make improvements - do not remove existing content unless it's redundant - -{custom_instructions_text} - -**Current File Content:** -``` -{file_content} -``` - -**Context for improvements:** -``` -{old_text} -``` - -Return the complete updated file content now (NO explanatory text):""" - - else: - # Specific replacement when new text is provided - prompt = f"""**Instructions:** - -Task: Update the documentation file with the changes requested. - -Steps to complete: - -1. Review the current file content below -2. Find the reference text that needs to be updated -3. Replace it with the suggested new content -4. Maintain the existing formatting, indentation, and markdown structure -5. Return the complete updated file content - -> [!IMPORTANT] -> OUTPUT REQUIREMENTS: -> - Return ONLY the complete file content - no explanatory text, dialog, or commentary -> - Do NOT add any text before or after the file content -> - Do NOT wrap output in markdown code blocks (```), just return the raw content -> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...] -> - Every single line of the original document must be present in your response -> - Only replace the specified text - do not make additional changes -> - Preserve all markdown formatting, links, and code blocks exactly -> - If the current text cannot be found exactly, search for similar text -> - Please ensure the changes align with Microsoft documentation standards -> - Do not remove any text unless the reference or suggested guidance indicates to do so - -{custom_instructions_text} - -**Current File Content:** -``` -{file_content} -``` - -**Reference text to find and replace:** -``` -{old_text} -``` - -**Suggested new content:** -``` -{new_text} -``` - -Return the complete updated file content now (NO explanatory text):""" - - response = client.chat.completions.create( - model="gpt-4-turbo-preview", - messages=[ - {"role": "system", "content": "You are a document editor. Return ONLY the complete updated file content - no explanatory text, no dialog, no code blocks, no truncation, no placeholders. Output must be the raw complete file content with requested changes."}, - {"role": "user", "content": prompt} - ], - temperature=0.1 - ) - - updated_content = response.choices[0].message.content.strip() - - # Clean up code blocks if AI wrapped it - if updated_content.startswith('```'): - updated_content = '\n'.join(updated_content.split('\n')[1:-1]) - - # Basic validation - ensure content was actually changed - if updated_content and updated_content != file_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - self.logger.log(f"āœ… ChatGPT document update successful ({changed_lines} lines affected)") - return updated_content - else: - self.logger.log("āš ļø No changes detected in AI response") - return None - - except Exception as e: - self.logger.log(f"āŒ Error generating updated document with ChatGPT: {str(e)}") - return None - - def _generate_with_context_window(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Use context window approach - AI only sees/modifies a small section - - This physically prevents AI from rewriting entire file by only giving it - the relevant section to work with. - - Args: - file_content: Full file content - old_text: Reference text (guides where to look) - new_text: Suggestions (what to change to) - """ - try: - import difflib - - # Step 1: Find where the reference text is located - lines = file_content.split('\n') - ref_lines = old_text.split('\n') if old_text else [] - - # Find best matching location for reference text - start_line = 0 - if ref_lines: - matcher = difflib.SequenceMatcher(None, ref_lines, lines) - match = matcher.find_longest_match(0, len(ref_lines), 0, len(lines)) - if match.size > 0: - start_line = match.b - self.logger.log(f"šŸ“ Found reference area at line {start_line + 1}") - else: - self.logger.log("šŸ“ Reference text not found, using beginning of file") - - # Step 2: Extract context window (30 lines before, 30 lines after) - window_before = 30 - window_after = 30 - - window_start = max(0, start_line - window_before) - window_end = min(len(lines), start_line + len(ref_lines) + window_after) - - context_window = lines[window_start:window_end] - self.logger.log(f"šŸ“„ Context window: lines {window_start + 1} to {window_end} ({len(context_window)} lines)") - self.logger.log(f" (AI can only modify this section, rest of file is protected)") - - # Step 3: Have AI modify only the context window - context_text = '\n'.join(context_window) - - import openai - client = openai.OpenAI(api_key=self.api_key) - - prompt = f"""You are helping modify a small section of a documentation file. You can ONLY modify the section provided below. - -File: {file_path} -Section location: Lines {window_start + 1} to {window_end} - -REFERENCE TEXT (what user is referring to): -{old_text} - -SUGGESTED CHANGES (what user wants): -{new_text} - -SECTION TO MODIFY: -``` -{context_text} -``` - -INSTRUCTIONS: -1. Understand the user's INTENT from the reference and suggestions: - - "add/include/incorporate a section" = Add a COMPLETE NEW SECTION with heading and full content - - "update/modify/change X" = Modify existing text X intelligently - - "fix/correct" = Make specific correction only - - Be generous with new content when asked to add something - -2. For ADDING content (sections, paragraphs, examples): - - Create complete, well-written content (not just stubs or brief additions) - - Add proper markdown headers (## Best Practices, ### Example, etc.) - - Place it logically (end of section, before ## Related content, etc.) - - Match the document's writing style and tone - -3. For MODIFYING content: - - Change only what's requested - - Leave everything else exactly as-is - -4. Return the ENTIRE section (all {len(context_window)} lines) with your changes -5. No explanations - just the modified section - -OUTPUT THE COMPLETE MODIFIED SECTION:""" - - response = client.chat.completions.create( - model="gpt-4-turbo-preview", - messages=[ - {"role": "system", "content": "You make precise, targeted edits to documentation sections. Return only the modified text, nothing else."}, - {"role": "user", "content": prompt} - ], - temperature=0.1 - ) - - modified_window = response.choices[0].message.content.strip() - - # Clean up code blocks if AI wrapped it - if modified_window.startswith('```'): - modified_window = '\n'.join(modified_window.split('\n')[1:-1]) - - # Step 4: Replace the context window in the full file - modified_lines = modified_window.split('\n') - result_lines = lines[:window_start] + modified_lines + lines[window_end:] - updated_content = '\n'.join(result_lines) - - # Verify change is minimal - diff = list(difflib.unified_diff(lines, result_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - self.logger.log(f"āœ… Context window approach successful ({changed_lines} lines changed)") - - # Ensure we actually made changes - if updated_content == file_content: - self.logger.log("āš ļø No changes detected, falling back to full-document approach") - return self._generate_updated_document_chatgpt(file_content, old_text, new_text, file_path) - - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error with context window approach: {str(e)}") - self.logger.log("āš ļø Falling back to full-document approach") - return self._generate_updated_document_chatgpt(file_content, old_text, new_text, file_path) - - def _validate_diff_patch(self, diff_patch: str, original_content: str, old_text: str, new_text: str) -> bool: - """Validate that the AI-generated diff is safe and appropriate""" - try: - # Check for common problems - lines = diff_patch.split('\n') - - # Problem 0: Check for proper diff structure - has_hunk_header = any(line.startswith('@@') for line in lines) - if not has_hunk_header: - self.logger.log("āŒ Invalid diff: Missing @@ hunk headers") - return False - - # Problem 1: Check for duplicate +++ lines - plus_count = sum(1 for line in lines if line.startswith('+++')) - if plus_count > 1: - self.logger.log("āŒ Invalid diff: Multiple +++ lines detected") - return False - - # Problem 2: Check for removal of metadata (title, author, etc.) - for line in lines: - if line.startswith('-') and not line.startswith('---'): - removed_content = line[1:].strip() - # Check if removing metadata - if any(keyword in removed_content.lower() for keyword in ['title:', 'author:', 'description:', 'ms.author:', 'ms.date:']): - self.logger.log(f"āŒ Invalid diff: Attempting to remove metadata: {removed_content}") - return False - - # Problem 3: Check if diff is too large (indicates rewrite) - removed_lines = len([line for line in lines if line.startswith('-') and not line.startswith('---')]) - added_lines = len([line for line in lines if line.startswith('+') and not line.startswith('+++')]) - - if removed_lines > 10: # Too many removals for an additive change - self.logger.log(f"āŒ Invalid diff: Too many removals ({removed_lines} lines)") - return False - - return True - - except Exception as e: - self.logger.log(f"āŒ Error validating diff: {str(e)}") - return False - - def _create_safe_diff(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Create a safer, simpler diff that just adds content without removing anything""" - try: - # Strategy: Find the best location to add the new content and insert it there - lines = file_content.split('\n') - - # Look for common insertion points for adding sections - insertion_point = self._find_safe_insertion_point(lines, old_text, new_text) - - if insertion_point is None: - self.logger.log("āš ļø Could not find safe insertion point") - return None - - # Insert the new content at the found location - new_lines = lines[:insertion_point] + [new_text.strip(), ''] + lines[insertion_point:] - updated_content = '\n'.join(new_lines) - - self.logger.log(f"āœ… Created safe diff - inserting content at line {insertion_point}") - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error creating safe diff: {str(e)}") - return None - - def _find_safe_insertion_point(self, lines: list, old_text: str, new_text: str) -> Optional[int]: - """Find the best place to insert new content safely""" - try: - # Look for section headers to insert after - for i, line in enumerate(lines): - # If the old_text contains context about where to insert - if old_text and old_text.lower().strip() in line.lower(): - # Insert after this line - return i + 1 - - # Look for pattern where we should insert a new section - # Insert before conclusion, examples, or other sections - if line.strip().startswith('##') and any(keyword in line.lower() for keyword in ['example', 'conclusion', 'summary', 'next steps']): - return i - - # If no specific location found, insert before the last section - for i in range(len(lines) - 1, -1, -1): - if lines[i].strip().startswith('##'): - return i - - # Last resort: insert at 80% through the document - return int(len(lines) * 0.8) - - except Exception: - return None - - def _apply_diff_patch_chatgpt(self, original_content: str, diff_patch: str, file_path: str) -> Optional[str]: - """Apply a unified diff patch to the original content""" - try: - import tempfile - import subprocess - import os - - # Create temporary files - with tempfile.TemporaryDirectory() as temp_dir: - # Write original content to temp file - original_file = os.path.join(temp_dir, "original.txt") - with open(original_file, 'w', encoding='utf-8') as f: - f.write(original_content) - - # Write diff patch to temp file - patch_file = os.path.join(temp_dir, "changes.patch") - with open(patch_file, 'w', encoding='utf-8') as f: - f.write(diff_patch) - - # Apply patch using git apply - try: - subprocess.run(['git', 'apply', '--verbose', patch_file], - cwd=temp_dir, check=True, capture_output=True, text=True) - - # Read the result - with open(original_file, 'r', encoding='utf-8') as f: - return f.read() - - except subprocess.CalledProcessError: - # Fallback to manual patch application - self.logger.log("šŸ“ Git apply failed, trying manual diff application...") - return self._manual_diff_apply_chatgpt(original_content, diff_patch) - - except Exception as e: - self.logger.log(f"āš ļø ChatGPT patch application failed: {str(e)}") - return self._manual_diff_apply_chatgpt(original_content, diff_patch) - - def _manual_diff_apply_chatgpt(self, original_content: str, diff_patch: str) -> Optional[str]: - """Manually apply a diff patch when git apply fails""" - try: - # Detect original line ending style - has_crlf = '\r\n' in original_content - - original_lines = original_content.split('\n') - result_lines = original_lines.copy() - - # Parse the diff patch - diff_lines = diff_patch.split('\n') - current_original_line = 0 - - i = 0 - while i < len(diff_lines): - line = diff_lines[i] - - # Look for @@ headers - if line.startswith('@@'): - # Extract line numbers: @@ -start,count +start,count @@ - parts = line.split() - if len(parts) >= 3: - old_info = parts[1][1:] # Remove the - - if ',' in old_info: - start_line = int(old_info.split(',')[0]) - 1 # Convert to 0-based - else: - start_line = int(old_info) - 1 - - current_original_line = start_line - i += 1 - continue - - # Skip diff headers (must check before processing -/+ lines) - if line.startswith('---') or line.startswith('+++'): - i += 1 - continue - - # Process diff lines - if line.startswith('-'): - # Remove line - if current_original_line < len(result_lines): - del result_lines[current_original_line] - elif line.startswith('+'): - # Add line - new_line = line[1:] # Remove the + - # If original had CRLF and this line doesn't have \r, add it - if has_crlf and not new_line.endswith('\r'): - new_line = new_line + '\r' - result_lines.insert(current_original_line, new_line) - current_original_line += 1 - elif line.startswith(' '): - # Context line - advance - current_original_line += 1 - - i += 1 - - return '\n'.join(result_lines) - - except Exception as e: - self.logger.log(f"āŒ ChatGPT manual diff application failed: {str(e)}") - return None - - def _detect_change_type(self, old_text: str, new_text: str, file_path: str) -> str: - """Detect the type of change requested""" - old_lower = old_text.lower() - new_lower = new_text.lower() - - # Additive indicators - additive_keywords = ['add', 'include', 'incorporate', 'insert', 'create section', 'new section', 'best practices'] - if any(keyword in old_lower or keyword in new_lower for keyword in additive_keywords): - return "ADDITIVE" - - # Corrective indicators - corrective_keywords = ['correct', 'fix', 'grammar', 'spelling', 'typo', 'misspell', 'wrong', 'error'] - if any(keyword in old_lower or keyword in new_lower for keyword in corrective_keywords): - return "CORRECTIVE" - - # If new text is much longer than old text, likely additive - if len(new_text.strip()) > len(old_text.strip()) * 2: - return "ADDITIVE" - - # If similar length, likely corrective - if abs(len(new_text.strip()) - len(old_text.strip())) < 50: - return "CORRECTIVE" - - return "GENERAL" - - def _handle_additive_change_chatgpt(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle additive changes using ChatGPT""" - self.logger.log("šŸ”Ø ChatGPT handling additive change - generating new content...") - - try: - import openai - client = openai.OpenAI(api_key=self.api_key) - - prompt = f"""**Instructions:** - -Task: Add new content to the documentation file as requested. - -Steps to complete: - -1. Generate ONLY the new content that should be added to the documentation file -2. Maintain proper formatting, indentation, and markdown structure -3. Make content standalone - don't reference existing content in the file -4. Use Microsoft documentation standards - -> [!IMPORTANT] -> Only create the new content - do not rewrite or modify existing content. -> Preserve markdown formatting, links, and code blocks as appropriate. -> Please ensure the changes align with Microsoft documentation standards. - -File: {file_path} -Request: {old_text} -Content to add: {new_text} - -Generate only the new content that should be added:""" - - response = client.chat.completions.create( - model="gpt-4-turbo-preview", - messages=[ - {"role": "system", "content": "You are a content generator. Generate only new content, never rewrite existing content."}, - {"role": "user", "content": prompt} - ], - temperature=0.1 - ) - - new_content = response.choices[0].message.content.strip() - - # Find best insertion point and insert - insertion_point = self._find_insertion_point(file_content, old_text, file_path) - lines = file_content.split('\n') - lines.insert(insertion_point, '\n' + new_content + '\n') - updated_content = '\n'.join(lines) - - # Count actual changes - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+')]) - - self.logger.log(f"āœ… ChatGPT added new content ({changed_lines} lines added)") - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error in ChatGPT additive change: {str(e)}") - return None - - def _handle_corrective_change_chatgpt(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle corrective changes using ChatGPT""" - self.logger.log("šŸ” ChatGPT handling corrective change - finding specific issues...") - - try: - import openai - client = openai.OpenAI(api_key=self.api_key) - - prompt = f"""**Instructions:** - -Task: Find and fix a specific issue in the documentation file. - -Steps to complete: - -1. Locate the exact text that needs to be corrected in the file -2. Provide the precise replacement text -3. Make minimal changes - fix only what needs to be fixed -4. Maintain existing formatting and structure - -> [!IMPORTANT] -> Only make the specified correction - do not make additional changes. -> Preserve all markdown formatting, links, and code blocks. -> Please ensure the changes align with Microsoft documentation standards. - -Issue: {old_text} -Fix: {new_text} -File: {file_path} - -Return your response in this format: -OLD: [exact text to find] -NEW: [exact replacement text] - -Be very specific - find the minimal text that needs changing. For example: -- If fixing "Microsft" → return OLD: Microsft, NEW: Microsoft -- If fixing grammar → return OLD: [the incorrect phrase], NEW: [corrected phrase] - -File content to search: -{file_content} - -Find the exact text to correct:""" - - response = client.chat.completions.create( - model="gpt-4-turbo-preview", - messages=[ - {"role": "system", "content": "You are a precise error detector. Find exact text that needs correction."}, - {"role": "user", "content": prompt} - ], - temperature=0.1 - ) - - response_text = response.choices[0].message.content.strip() - - # Parse OLD and NEW - old_match = None - new_match = None - - for line in response_text.split('\n'): - if line.startswith('OLD:'): - old_match = line[4:].strip() - elif line.startswith('NEW:'): - new_match = line[4:].strip() - - if old_match and new_match and old_match in file_content: - updated_content = file_content.replace(old_match, new_match) - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - self.logger.log(f"āœ… ChatGPT corrective change successful ({changed_lines} lines affected)") - return updated_content - else: - self.logger.log(f"āš ļø ChatGPT could not find exact text to correct") - return None - - except Exception as e: - self.logger.log(f"āŒ Error in ChatGPT corrective change: {str(e)}") - return None - - def _handle_general_change_chatgpt(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle general changes using ChatGPT with enhanced targeting""" - self.logger.log("šŸŽÆ ChatGPT handling general change with enhanced targeting...") - - max_retries = 3 - base_delay = 2 - - for attempt in range(max_retries): - try: - import openai - client = openai.OpenAI(api_key=self.api_key) - - prompt = f"""You are helping make a specific text change in a documentation file. - -File: {file_path} -Change needed: {old_text} -New content: {new_text} - -CRITICAL: Make the SMALLEST possible change. Do not rewrite or reorganize content. - -Your task: -1. Find the specific section that needs changing -2. Make ONLY that change -3. Preserve everything else exactly as-is -4. Return the complete updated file - -Current file content: -{file_content}""" - - response = client.chat.completions.create( - model="gpt-4-turbo-preview", - messages=[ - {"role": "system", "content": "You are a precise file editor. Make minimal targeted changes only."}, - {"role": "user", "content": prompt} - ], - temperature=0.1 - ) - - updated_content = response.choices[0].message.content - - if new_text.strip() in updated_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - if changed_lines > 30: - self.logger.log(f"āš ļø ChatGPT change affected {changed_lines} lines - may be too broad") - else: - self.logger.log(f"āœ… ChatGPT general change successful ({changed_lines} lines affected)") - - return updated_content - else: - self.logger.log("āš ļø ChatGPT: New text not found in result") - return None - - except Exception as e: - if attempt < max_retries - 1: - delay = base_delay * (2 ** attempt) - self.logger.log(f"āš ļø ChatGPT retry {attempt + 1}/{max_retries} after {delay}s...") - time.sleep(delay) - continue - else: - self.logger.log(f"āŒ Error in ChatGPT general change: {str(e)}") - return None - - return None - - def _find_insertion_point(self, file_content: str, context: str, file_path: str) -> int: - """Find the best place to insert new content""" - lines = file_content.split('\n') - - # For markdown files, try to find a good section to add after - if file_path.endswith('.md'): - # Look for existing sections - for i, line in enumerate(lines): - if line.startswith('#') and i < len(lines) - 1: - # Insert after this section - continue - - # If no good sections found, add at the end - return len(lines) - - # For other files, add at the end - return len(lines) - - -class GitHubCopilotProvider(AIProvider): - """GitHub Copilot provider using GitHub Models API""" - - # GitHub Models API endpoint - GITHUB_MODELS_API_URL = "https://models.inference.ai.azure.com/chat/completions" - - def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: - """Use diff-based approach for surgical edits""" - - # Step 1: Always try direct string replacement first (most accurate) - if old_text and old_text.strip() in file_content: - self.logger.log("āœ… Making direct string replacement (most precise)") - updated_content = file_content.replace(old_text.strip(), new_text.strip()) - if updated_content != file_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - self.logger.log(f"āœ… Direct replacement successful ({changed_lines} lines changed)") - return updated_content - - # Step 2: Use AI to generate full document with targeted changes - self.logger.log("šŸ“ Using GitHub Copilot to modify the document...") - return self._generate_updated_document_copilot(file_content, old_text, new_text, file_path, custom_instructions) - - def _generate_updated_document_copilot(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: - """Generate updated document content using GitHub Copilot""" - - try: - import requests - - url = self.GITHUB_MODELS_API_URL - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - - # Build custom instructions text - if custom_instructions and custom_instructions.strip(): - custom_instructions_text = f""" - -**Additional Custom Instructions:** -{custom_instructions.strip()} - -""" - else: - custom_instructions_text = "" - - # Handle blank new_text field with dynamic prompt - if not new_text or not new_text.strip(): - # General improvement request when new text is blank - prompt = f"""**Instructions:** - -Task: Review and improve the documentation file based on the reference context provided. - -Steps to complete: - -1. Review the current file content below -2. Look at the reference context: "{old_text}" -3. Improve the relevant sections based on Microsoft documentation standards -4. Maintain the existing formatting, indentation, and markdown structure -5. Return the complete updated file content - -> [!IMPORTANT] -> OUTPUT REQUIREMENTS: -> - Return ONLY the complete file content - no explanatory text, dialog, or commentary -> - Do NOT add any text before or after the file content -> - Do NOT wrap output in markdown code blocks (```), just return the raw content -> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...] -> - Every single line of the original document must be present in your response -> - Focus on areas related to: {old_text} -> - Preserve all markdown formatting, links, and code blocks exactly -> - Please ensure improvements align with Microsoft documentation standards -> - Only make improvements - do not remove existing content unless it's redundant - -{custom_instructions_text} - -**Current File Content:** -``` -{file_content} -``` - -**Context for improvements:** -``` -{old_text} -``` - -Return the complete updated file content now (NO explanatory text):""" - - else: - # Specific replacement when new text is provided - prompt = f"""**Instructions:** - -Task: Update the documentation file with the changes requested. - -Steps to complete: - -1. Review the current file content below -2. Find the reference text that needs to be updated -3. Replace it with the suggested new content -4. Maintain the existing formatting, indentation, and markdown structure -5. Return the complete updated file content - -> [!IMPORTANT] -> OUTPUT REQUIREMENTS: -> - Return ONLY the complete file content - no explanatory text, dialog, or commentary -> - Do NOT add any text before or after the file content -> - Do NOT wrap output in markdown code blocks (```), just return the raw content -> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...] -> - Every single line of the original document must be present in your response -> - Only replace the specified text - do not make additional changes -> - Preserve all markdown formatting, links, and code blocks exactly -> - If the current text cannot be found exactly, search for similar text -> - Please ensure the changes align with Microsoft documentation standards -> - Do not remove any text unless the reference or suggested guidance indicates to do so - -{custom_instructions_text} - -**Current File Content:** -``` -{file_content} -``` - -**Reference text to find and replace:** -``` -{old_text} -``` - -**Suggested new content:** -``` -{new_text} -``` - -Return the complete updated file content now (NO explanatory text):""" - - data = { - "messages": [ - {"role": "system", "content": "You are a document editor. Return ONLY the complete updated file content - no explanatory text, no dialog, no code blocks, no truncation, no placeholders. Output must be the raw complete file content with requested changes."}, - {"role": "user", "content": prompt} - ], - "model": "gpt-4o", - "temperature": 0.1, - "max_tokens": 4096 - } - - response = requests.post(url, headers=headers, json=data, timeout=60) - response.raise_for_status() - - result = response.json() - updated_content = result['choices'][0]['message']['content'].strip() - - # Clean up code blocks if AI wrapped it - if updated_content.startswith('```'): - updated_content = '\n'.join(updated_content.split('\n')[1:-1]) - - # Basic validation - ensure content was actually changed - if updated_content and updated_content != file_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - self.logger.log(f"āœ… GitHub Copilot document update successful ({changed_lines} lines affected)") - return updated_content - else: - self.logger.log("āš ļø No changes detected in AI response") - return None - - except Exception as e: - self.logger.log(f"āŒ Error generating updated document with GitHub Copilot: {str(e)}") - return None - - def _validate_diff_patch(self, diff_patch: str, original_content: str, old_text: str, new_text: str) -> bool: - """Validate that the AI-generated diff is safe and appropriate""" - try: - # Check for common problems - lines = diff_patch.split('\n') - - # Problem 0: Check for proper diff structure - has_hunk_header = any(line.startswith('@@') for line in lines) - if not has_hunk_header: - self.logger.log("āŒ Invalid diff: Missing @@ hunk headers") - return False - - # Problem 1: Check for duplicate +++ lines - plus_count = sum(1 for line in lines if line.startswith('+++')) - if plus_count > 1: - self.logger.log("āŒ Invalid diff: Multiple +++ lines detected") - return False - - # Problem 2: Check for removal of metadata (title, author, etc.) - for line in lines: - if line.startswith('-') and not line.startswith('---'): - removed_content = line[1:].strip() - # Check if removing metadata - if any(keyword in removed_content.lower() for keyword in ['title:', 'author:', 'description:', 'ms.author:', 'ms.date:']): - self.logger.log(f"āŒ Invalid diff: Attempting to remove metadata: {removed_content}") - return False - - # Problem 3: Check if diff is too large (indicates rewrite) - removed_lines = len([line for line in lines if line.startswith('-') and not line.startswith('---')]) - added_lines = len([line for line in lines if line.startswith('+') and not line.startswith('+++')]) - - if removed_lines > 10: # Too many removals for an additive change - self.logger.log(f"āŒ Invalid diff: Too many removals ({removed_lines} lines)") - return False - - return True - - except Exception as e: - self.logger.log(f"āŒ Error validating diff: {str(e)}") - return False - - def _create_safe_diff(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Create a safer, simpler diff that just adds content without removing anything""" - try: - # Strategy: Find the best location to add the new content and insert it there - lines = file_content.split('\n') - - # Look for common insertion points for adding sections - insertion_point = self._find_safe_insertion_point(lines, old_text, new_text) - - if insertion_point is None: - self.logger.log("āš ļø Could not find safe insertion point") - return None - - # Insert the new content at the found location - new_lines = lines[:insertion_point] + [new_text.strip(), ''] + lines[insertion_point:] - updated_content = '\n'.join(new_lines) - - self.logger.log(f"āœ… Created safe diff - inserting content at line {insertion_point}") - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error creating safe diff: {str(e)}") - return None - - def _find_safe_insertion_point(self, lines: list, old_text: str, new_text: str) -> Optional[int]: - """Find the best place to insert new content safely""" - try: - # Look for section headers to insert after - for i, line in enumerate(lines): - # If the old_text contains context about where to insert - if old_text and old_text.lower().strip() in line.lower(): - # Insert after this line - return i + 1 - - # Look for pattern where we should insert a new section - # Insert before conclusion, examples, or other sections - if line.strip().startswith('##') and any(keyword in line.lower() for keyword in ['example', 'conclusion', 'summary', 'next steps']): - return i - - # If no specific location found, insert before the last section - for i in range(len(lines) - 1, -1, -1): - if lines[i].strip().startswith('##'): - return i - - # Last resort: insert at 80% through the document - return int(len(lines) * 0.8) - - except Exception: - return None - - def _apply_diff_patch_copilot(self, original_content: str, diff_patch: str, file_path: str) -> Optional[str]: - """Apply a unified diff patch to the original content""" - try: - import tempfile - import subprocess - import os - - # Create temporary files - with tempfile.TemporaryDirectory() as temp_dir: - # Write original content to temp file - original_file = os.path.join(temp_dir, "original.txt") - with open(original_file, 'w', encoding='utf-8') as f: - f.write(original_content) - - # Write diff patch to temp file - patch_file = os.path.join(temp_dir, "changes.patch") - with open(patch_file, 'w', encoding='utf-8') as f: - f.write(diff_patch) - - # Apply patch using git apply - try: - subprocess.run(['git', 'apply', '--verbose', patch_file], - cwd=temp_dir, check=True, capture_output=True, text=True) - - # Read the result - with open(original_file, 'r', encoding='utf-8') as f: - return f.read() - - except subprocess.CalledProcessError: - # Fallback to manual patch application - self.logger.log("šŸ“ Git apply failed, trying manual diff application...") - return self._manual_diff_apply_copilot(original_content, diff_patch) - - except Exception as e: - self.logger.log(f"āš ļø GitHub Copilot patch application failed: {str(e)}") - return self._manual_diff_apply_copilot(original_content, diff_patch) - - def _manual_diff_apply_copilot(self, original_content: str, diff_patch: str) -> Optional[str]: - """Manually apply a diff patch when git apply fails""" - try: - # Detect original line ending style - has_crlf = '\r\n' in original_content - - original_lines = original_content.split('\n') - result_lines = original_lines.copy() - - # Parse the diff patch - diff_lines = diff_patch.split('\n') - current_original_line = 0 - - i = 0 - while i < len(diff_lines): - line = diff_lines[i] - - # Look for @@ headers - if line.startswith('@@'): - # Extract line numbers: @@ -start,count +start,count @@ - parts = line.split() - if len(parts) >= 3: - old_info = parts[1][1:] # Remove the - - if ',' in old_info: - start_line = int(old_info.split(',')[0]) - 1 # Convert to 0-based - else: - start_line = int(old_info) - 1 - - current_original_line = start_line - i += 1 - continue - - # Skip diff headers (must check before processing -/+ lines) - if line.startswith('---') or line.startswith('+++'): - i += 1 - continue - - # Process diff lines - if line.startswith('-'): - # Remove line - if current_original_line < len(result_lines): - del result_lines[current_original_line] - elif line.startswith('+'): - # Add line - new_line = line[1:] # Remove the + - # If original had CRLF and this line doesn't have \r, add it - if has_crlf and not new_line.endswith('\r'): - new_line = new_line + '\r' - result_lines.insert(current_original_line, new_line) - current_original_line += 1 - elif line.startswith(' '): - # Context line - advance - current_original_line += 1 - - i += 1 - - return '\n'.join(result_lines) - - except Exception as e: - self.logger.log(f"āŒ GitHub Copilot manual diff application failed: {str(e)}") - return None - - def _detect_change_type(self, old_text: str, new_text: str, file_path: str) -> str: - """Detect the type of change requested""" - old_lower = old_text.lower() - new_lower = new_text.lower() - - # Additive indicators - additive_keywords = ['add', 'include', 'incorporate', 'insert', 'create section', 'new section', 'best practices'] - if any(keyword in old_lower or keyword in new_lower for keyword in additive_keywords): - return "ADDITIVE" - - # Corrective indicators - corrective_keywords = ['correct', 'fix', 'grammar', 'spelling', 'typo', 'misspell', 'wrong', 'error'] - if any(keyword in old_lower or keyword in new_lower for keyword in corrective_keywords): - return "CORRECTIVE" - - # If new text is much longer than old text, likely additive - if len(new_text.strip()) > len(old_text.strip()) * 2: - return "ADDITIVE" - - # If similar length, likely corrective - if abs(len(new_text.strip()) - len(old_text.strip())) < 50: - return "CORRECTIVE" - - return "GENERAL" - - def _handle_additive_change_copilot(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle additive changes using GitHub Copilot""" - self.logger.log("šŸ”Ø GitHub Copilot handling additive change - generating new content...") - - try: - import requests - - url = self.GITHUB_MODELS_API_URL - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - - prompt = f"""You are helping add new content to a documentation file. - -File: {file_path} -Request: {old_text} -Content to add: {new_text} - -Your task: Generate ONLY the new content that should be added. Do not rewrite the existing file. - -Rules: -1. Generate ONLY the new section/content to be added -2. Use proper markdown formatting if it's a markdown file -3. Make it standalone - don't reference existing content -4. Do not include any existing file content in your response -5. Return only the new content, nothing else - -Generate the new content now:""" - - data = { - "messages": [ - {"role": "system", "content": "You are a content generator. Generate only new content, never rewrite existing content."}, - {"role": "user", "content": prompt} - ], - "model": "gpt-4o", - "temperature": 0.1, - "max_tokens": 2048 - } - - response = requests.post(url, headers=headers, json=data, timeout=60) - response.raise_for_status() - - result = response.json() - new_content = result['choices'][0]['message']['content'].strip() - - # Clean up markdown blocks if needed - if new_content.startswith("```") and new_content.endswith("```"): - lines = new_content.split('\n') - if len(lines) > 2: - new_content = '\n'.join(lines[1:-1]) - - # Find insertion point and insert - insertion_point = self._find_insertion_point(file_content, old_text, file_path) - lines = file_content.split('\n') - lines.insert(insertion_point, '\n' + new_content + '\n') - updated_content = '\n'.join(lines) - - # Count actual changes - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+')]) - - self.logger.log(f"āœ… GitHub Copilot added new content ({changed_lines} lines added)") - return updated_content - - except Exception as e: - self.logger.log(f"āŒ Error in GitHub Copilot additive change: {str(e)}") - return None - - def _handle_corrective_change_copilot(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle corrective changes using GitHub Copilot""" - self.logger.log("šŸ” GitHub Copilot handling corrective change - finding specific issues...") - - try: - import requests - - url = self.GITHUB_MODELS_API_URL - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - - prompt = f"""You are helping fix a specific issue in a documentation file. - -Issue: {old_text} -Fix: {new_text} -File: {file_path} - -Your task: Find the EXACT text that needs to be corrected and provide the EXACT replacement. - -Return your response in this format: -OLD: [exact text to find] -NEW: [exact replacement text] - -Be very specific - find the minimal text that needs changing. For example: -- If fixing "Microsft" → return OLD: Microsft, NEW: Microsoft -- If fixing grammar → return OLD: [the incorrect phrase], NEW: [corrected phrase] - -File content to search: -{file_content} - -Find the exact text to correct:""" - - data = { - "messages": [ - {"role": "system", "content": "You are a precise error detector. Find exact text that needs correction."}, - {"role": "user", "content": prompt} - ], - "model": "gpt-4o", - "temperature": 0.1, - "max_tokens": 1024 - } - - response = requests.post(url, headers=headers, json=data, timeout=60) - response.raise_for_status() - - result = response.json() - response_text = result['choices'][0]['message']['content'].strip() - - # Parse OLD and NEW - old_match = None - new_match = None - - for line in response_text.split('\n'): - if line.startswith('OLD:'): - old_match = line[4:].strip() - elif line.startswith('NEW:'): - new_match = line[4:].strip() - - if old_match and new_match and old_match in file_content: - updated_content = file_content.replace(old_match, new_match) - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - self.logger.log(f"āœ… GitHub Copilot corrective change successful ({changed_lines} lines affected)") - return updated_content - else: - self.logger.log(f"āš ļø GitHub Copilot could not find exact text to correct") - return None - - except Exception as e: - self.logger.log(f"āŒ Error in GitHub Copilot corrective change: {str(e)}") - return None - - def _handle_general_change_copilot(self, file_content: str, old_text: str, new_text: str, file_path: str) -> Optional[str]: - """Handle general changes using GitHub Copilot with enhanced targeting""" - self.logger.log("šŸŽÆ GitHub Copilot handling general change with enhanced targeting...") - - try: - import requests - - url = self.GITHUB_MODELS_API_URL - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - - prompt = f"""You are helping make a specific text change in a documentation file. - -File: {file_path} -Change needed: {old_text} -New content: {new_text} - -CRITICAL: Make the SMALLEST possible change. Do not rewrite or reorganize content. - -Your task: -1. Find the specific section that needs changing -2. Make ONLY that change -3. Preserve everything else exactly as-is -4. Return the complete updated file - -Current file content: -{file_content}""" - - data = { - "messages": [ - {"role": "system", "content": "You are a precise file editor. Make minimal targeted changes only."}, - {"role": "user", "content": prompt} - ], - "model": "gpt-4o", - "temperature": 0.1, - "max_tokens": 8000 - } - - response = requests.post(url, headers=headers, json=data, timeout=60) - response.raise_for_status() - - result = response.json() - updated_content = result['choices'][0]['message']['content'].strip() - - # Clean up markdown code blocks - if updated_content.startswith("```"): - lines = updated_content.split('\n') - if len(lines) > 2: - if lines[0].startswith("```") and lines[-1].strip() == "```": - updated_content = '\n'.join(lines[1:-1]) - - if new_text.strip() in updated_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - if changed_lines > 30: - self.logger.log(f"āš ļø GitHub Copilot change affected {changed_lines} lines - may be too broad") - else: - self.logger.log(f"āœ… GitHub Copilot general change successful ({changed_lines} lines affected)") - - return updated_content - else: - self.logger.log("āš ļø GitHub Copilot: New text not found in result") - return None - - except Exception as e: - self.logger.log(f"āŒ Error in GitHub Copilot general change: {str(e)}") - return None - - def _find_insertion_point(self, file_content: str, context: str, file_path: str) -> int: - """Find the best place to insert new content""" - lines = file_content.split('\n') - - # For markdown files, try to find a good section to add after - if file_path.endswith('.md'): - # Look for existing sections - for i, line in enumerate(lines): - if line.startswith('#') and i < len(lines) - 1: - # Insert after this section - continue - - # If no good sections found, add at the end - return len(lines) - - # For other files, add at the end - return len(lines) - - -class LocalGitManager: - """Manages local git operations for making changes before creating PRs""" - - def __init__(self, logger: Logger, github_token: str): - self.logger = logger - self.github_token = github_token - self.last_diff_content = "" # Store the last generated diff content - - def get_repo_path(self, owner: str, repo: str, local_path: Optional[str] = None) -> Path: - """Get or create local repository path - - Args: - owner: Repository owner - repo: Repository name - local_path: Base path from LOCAL_REPO_PATH setting - - Returns: - Full path to the repository (base/owner/repo) - """ - # If LOCAL_REPO_PATH is configured, use it as the base directory - if local_path and local_path.strip(): - base_path = Path(local_path.strip()) - - # Warn if OneDrive path detected - if 'OneDrive' in str(base_path): - self.logger.log("āš ļø WARNING: Local Repo Path is in a OneDrive folder") - self.logger.log(" OneDrive sync can cause file locking issues with git operations") - self.logger.log(" Consider using a non-OneDrive location (e.g., C:\\git\\repos)") - - # Create base directory if it doesn't exist - if not base_path.exists(): - self.logger.log(f"Creating local repo directory: {base_path}") - try: - base_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - self.logger.log(f"āš ļø Could not create directory {base_path}: {e}") - self.logger.log(" Falling back to default location") - # Fall through to default - else: - # Successfully created or exists, use it - repo_path = base_path / owner / repo - return repo_path - else: - # Base path exists, use it - repo_path = base_path / owner / repo - return repo_path - - # Default: Use Downloads folder (typically not in OneDrive) - downloads = Path.home() / "Downloads" - repo_path = downloads / "github_repos" / owner / repo - return repo_path - - def clone_or_pull_repo(self, owner: str, repo: str, local_path: Optional[str] = None) -> Optional[Path]: - """Clone repository if it doesn't exist, or pull latest changes if it does""" - try: - import git - import gc - - repo_path = self.get_repo_path(owner, repo, local_path) - repo_url = f"https://{self.github_token}@github.com/{owner}/{repo}.git" - - if repo_path.exists() and (repo_path / ".git").exists(): - # Repository exists, try to update it - self.logger.log(f"Repository exists at {repo_path}, updating...") - git_repo = None - try: - git_repo = git.Repo(repo_path) - - # Try alternative update methods that are more reliable - try: - # Method 1: Fetch and reset (more reliable than pull) - self.logger.log("Fetching latest changes...") - git_repo.git.fetch('origin') - - # Make sure we're on main/master - try: - git_repo.git.checkout('main') - git_repo.git.reset('--hard', 'origin/main') - except: - git_repo.git.checkout('master') - git_repo.git.reset('--hard', 'origin/master') - - self.logger.log("āœ… Repository updated successfully") - return repo_path - except Exception as fetch_error: - self.logger.log(f"āš ļø Fetch/reset failed: {fetch_error}") - # Try simple pull as fallback - origin = git_repo.remotes.origin - origin.pull() - self.logger.log("āœ… Pulled latest changes") - return repo_path - - except Exception as e: - self.logger.log(f"āš ļø Error updating repo: {e}") - self.logger.log("Repository will be reused as-is for this operation") - - # Don't try to delete - just reuse the existing repo - # This avoids file locking issues - return repo_path - finally: - # Always clean up - if git_repo: - try: - git_repo.close() - git_repo.__del__() - except: - pass - git_repo = None - gc.collect() - - # Clone repository - self.logger.log(f"Cloning repository to {repo_path}...") - repo_path.parent.mkdir(parents=True, exist_ok=True) - git.Repo.clone_from(repo_url, repo_path) - self.logger.log("āœ… Repository cloned successfully") - return repo_path - - except ImportError: - self.logger.log("āŒ GitPython not installed. Run: pip install GitPython") - return None - except Exception as e: - self.logger.log(f"āŒ Error with git operations: {str(e)}") - return None - - def _safe_remove_tree(self, path: Path, max_retries: int = 3) -> bool: - """Safely remove a directory tree with retry logic for Windows file locking""" - import gc - - for attempt in range(max_retries): - try: - if path.exists(): - # On Windows, make files writable before deletion - if sys.platform == 'win32': - for root, _, files in os.walk(str(path)): - for fname in files: - fpath = os.path.join(root, fname) - try: - os.chmod(fpath, 0o777) - except: - pass - - shutil.rmtree(path, ignore_errors=False) - self.logger.log(f"āœ… Removed directory: {path}") - return True - except Exception as e: - if attempt < max_retries - 1: - self.logger.log(f"āš ļø Attempt {attempt + 1} failed to remove {path}: {e}") - gc.collect() # Force garbage collection - time.sleep(1) # Wait longer between retries - else: - self.logger.log(f"āŒ Failed to remove {path} after {max_retries} attempts: {e}") - self.logger.log(f"šŸ’” TIP: Close any file explorers or editors that might have this folder open") - return False - return False - - def apply_diff_and_commit(self, repo_path: Path, branch_name: str, - file_path: str, diff_patch: str, commit_message: str) -> bool: - """Apply diff patch using git apply and commit changes - - This is the preferred method as it uses native git to apply patches, - which properly handles line endings, whitespace, and other edge cases. - """ - git_repo = None - try: - import git - import tempfile - import os - - git_repo = git.Repo(repo_path) - - # Create new branch from main - self.logger.log(f"Creating branch {branch_name}...") - try: - git_repo.git.checkout('main') - git_repo.git.pull() - except: - git_repo.git.checkout('master') - git_repo.git.pull() - - git_repo.git.checkout('-b', branch_name) - self.logger.log(f"āœ… Branch {branch_name} created") - - # Write diff patch to temp file - # Ensure patch ends with newline for git apply compatibility - patch_content = diff_patch if diff_patch.endswith('\n') else diff_patch + '\n' - - with tempfile.NamedTemporaryFile(mode='w', suffix='.patch', delete=False, encoding='utf-8', newline='\n') as patch_file: - patch_file.write(patch_content) - patch_file_path = patch_file.name - - try: - # Apply patch using git apply - self.logger.log(f"Applying diff patch to {file_path}...") - self.logger.log(f"Patch file: {patch_file_path}") - - try: - git_repo.git.apply('--verbose', '--whitespace=nowarn', patch_file_path) - self.logger.log("āœ… Diff patch applied successfully using git apply") - except Exception as apply_error: - self.logger.log(f"āš ļø git apply failed: {str(apply_error)}") - - # Log the patch content for debugging - self.logger.log("šŸ“„ Patch content (first 1000 chars):") - self.logger.log(patch_content[:1000]) - - self.logger.log("šŸ“ Attempting to apply patch with --3way merge...") - try: - # Try with 3-way merge which is more forgiving - git_repo.git.apply('--3way', '--whitespace=nowarn', patch_file_path) - self.logger.log("āœ… Diff patch applied using 3-way merge") - except Exception as merge_error: - self.logger.log(f"āš ļø 3-way merge also failed: {str(merge_error)}") - - # Try one more time with --ignore-whitespace - self.logger.log("šŸ“ Attempting with --ignore-whitespace...") - try: - git_repo.git.apply('--ignore-whitespace', '--whitespace=nowarn', patch_file_path) - self.logger.log("āœ… Diff patch applied with --ignore-whitespace") - except: - self.logger.log("āŒ All git apply methods failed") - # Keep the patch file for debugging - self.logger.log(f"šŸ’¾ Patch file saved for debugging: {patch_file_path}") - raise - - # Stage and commit - git_repo.index.add([file_path]) - git_repo.index.commit(commit_message) - self.logger.log("āœ… Changes committed") - - return True - - finally: - # Clean up temp patch file only on success - if git_repo and git_repo.head.is_valid(): - try: - os.unlink(patch_file_path) - except: - pass - - except Exception as e: - self.logger.log(f"āŒ Error applying diff and committing: {str(e)}") - self.logger.log("šŸ’” This may indicate the file has changed since it was fetched") - return False - finally: - if git_repo: - try: - git_repo.close() - git_repo.__del__() - except: - pass - import gc - gc.collect() - - def create_branch_and_commit(self, repo_path: Path, branch_name: str, - file_path: str, updated_content: str, - commit_message: str, line_ending: str = '\n') -> bool: - """Create branch, update file, and commit - - Args: - line_ending: Original line ending style to preserve ('\n' or '\r\n') - - NOTE: This method is deprecated in favor of apply_diff_and_commit which uses git apply. - """ - git_repo = None - try: - import git - import gc - - git_repo = git.Repo(repo_path) - - # Create new branch from main - self.logger.log(f"Creating branch {branch_name}...") - try: - git_repo.git.checkout('main') - git_repo.git.pull() - except: - git_repo.git.checkout('master') - git_repo.git.pull() - - git_repo.git.checkout('-b', branch_name) - self.logger.log(f"āœ… Branch {branch_name} created") - - # Update the file - full_file_path = repo_path / file_path - if not full_file_path.exists(): - self.logger.log(f"āŒ File not found: {file_path}") - return False - - self.logger.log(f"Writing changes to {file_path}...") - - # Preserve original line endings - if line_ending == '\r\n': - # Normalize to CRLF if original had CRLF - content_to_write = updated_content.replace('\r\n', '\n').replace('\n', '\r\n') - self.logger.log(f"āœ… Preserving CRLF line endings") - else: - content_to_write = updated_content - - full_file_path.write_text(content_to_write, encoding='utf-8', newline='') - - # Stage and commit - git_repo.index.add([file_path]) - git_repo.index.commit(commit_message) - self.logger.log("āœ… Changes committed") - - return True - - except Exception as e: - self.logger.log(f"āŒ Error creating branch and committing: {str(e)}") - return False - finally: - if git_repo: - try: - git_repo.close() - git_repo.__del__() - except: - pass - import gc - gc.collect() # Force garbage collection to release file handles - - def push_branch(self, repo_path: Path, branch_name: str) -> bool: - """Push branch to remote""" - git_repo = None - try: - import git - import gc - - self.logger.log(f"Pushing branch {branch_name} to remote...") - git_repo = git.Repo(repo_path) - origin = git_repo.remotes.origin - origin.push(branch_name) - self.logger.log("āœ… Branch pushed to remote") - return True - - except Exception as e: - self.logger.log(f"āŒ Error pushing branch: {str(e)}") - return False - finally: - if git_repo: - try: - git_repo.close() - git_repo.__del__() - except: - pass - import gc - gc.collect() # Force garbage collection to release file handles - - def make_ai_assisted_change(self, owner: str, repo: str, branch_name: str, - file_path: str, old_text: str, new_text: str, - commit_message: str, ai_provider: AIProvider, - local_path: Optional[str] = None, custom_instructions: str = None) -> Tuple[bool, Optional[str]]: - """ - Complete workflow: clone, make TARGETED changes, commit, and push - This uses direct string replacement to avoid AI rewriting entire files - - Returns: - (success: bool, error_message: Optional[str]) - """ - try: - # Step 1: Clone or pull repository - repo_path = self.clone_or_pull_repo(owner, repo, local_path) - if not repo_path: - return False, "Failed to clone/pull repository" - - # Step 2: Read the current file - full_file_path = repo_path / file_path - if not full_file_path.exists(): - return False, f"File not found: {file_path}" - - self.logger.log(f"Reading file: {file_path}") - # Read in binary mode to detect and preserve line endings - raw_bytes = full_file_path.read_bytes() - current_content = raw_bytes.decode('utf-8') - - # Detect original line ending style - original_line_ending = '\r\n' if b'\r\n' in raw_bytes else '\n' - self.logger.log(f"šŸ“ Detected line endings: {'CRLF' if original_line_ending == '\\r\\n' else 'LF'}") - - # Normalize everything to LF for consistent processing - normalized_content = current_content.replace('\r\n', '\n') - normalized_old = old_text.replace('\r\n', '\n') - normalized_new = new_text.replace('\r\n', '\n') - - # Step 3: Make TARGETED change - updated_content = None - - # Strategy 1: Very conservative direct replacement (only for exact, specific content) - # Only use this for replacements where old_text is substantial and very specific - use_direct_replacement = ( - normalized_old.strip() and - len(normalized_old.strip()) > 20 and # Must be substantial content - normalized_old.strip().count('\n') >= 2 and # Must be multi-line - normalized_old.strip() in normalized_content and - normalized_content.count(normalized_old.strip()) == 1 # Must be unique match - ) - - if use_direct_replacement: - self.logger.log("āœ… Making very targeted direct replacement") - updated_content = normalized_content.replace(normalized_old.strip(), normalized_new.strip()) - - # Verify the replacement worked and was targeted - original_lines = normalized_content.split('\n') - updated_lines = updated_content.split('\n') - - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - - if changed_lines > 20: # If too many changes, something went wrong - self.logger.log(f"āš ļø Direct replacement affected {changed_lines} lines - falling back to AI") - updated_content = None # Fall back to AI - else: - self.logger.log(f"āœ… Direct replacement successful ({changed_lines} lines changed)") - - # Strategy 2: Use AI to generate complete updated document - if not updated_content: - self.logger.log("Using AI to modify complete document...") - self.logger.log(f"AI Provider type: {type(ai_provider).__name__}") - self.logger.log(f"Old text preview: {normalized_old[:100]}...") - self.logger.log(f"New text preview: {normalized_new[:100]}...") - - # Pass normalized versions to AI provider - try: - updated_content = ai_provider.make_change(normalized_content, normalized_old, normalized_new, file_path, custom_instructions) - if not updated_content: - self.logger.log("āŒ AI provider returned None or empty content") - return False, "AI failed to make the change - provider returned no content" - else: - self.logger.log(f"āœ… AI provider returned content ({len(updated_content)} characters)") - except Exception as e: - self.logger.log(f"āŒ AI provider threw exception: {str(e)}") - return False, f"AI failed to make the change - error: {str(e)}" - - # Step 4: Apply changes directly using file write method - # Restore original line endings in the updated content before writing - if original_line_ending == '\r\n': - updated_content_with_endings = updated_content.replace('\n', '\r\n') - else: - updated_content_with_endings = updated_content - - if not self.create_branch_and_commit(repo_path, branch_name, file_path, - updated_content_with_endings, commit_message): - return False, "Failed to apply changes using direct file write method" - - self.logger.log("āœ… Changes applied using direct file write method") - - # Step 5: Push to remote - if not self.push_branch(repo_path, branch_name): - return False, "Failed to push branch to remote" - - self.logger.log("āœ… AI-assisted changes completed successfully") - return True, None - - except Exception as e: - error_msg = f"Error in AI-assisted change workflow: {str(e)}" - self.logger.log(f"āŒ {error_msg}") - return False, error_msg - - def get_last_diff_content(self) -> str: - """Get the last generated diff content for display in the UI""" - return self.last_diff_content - - def clear_diff_content(self): - """Clear the stored diff content""" - self.last_diff_content = "" - - def get_git_diff_from_repo(self, repo_path: str, branch_name: str) -> str: - """Get the actual git diff from the repository for the specified branch""" - try: - import subprocess - import os - - self.logger.log(f"šŸ” Getting git diff from: {repo_path}") - self.logger.log(f"šŸ” Branch: {branch_name}") - - # Change to repo directory - original_dir = os.getcwd() - - if not os.path.exists(repo_path): - self.logger.log(f"āŒ Repository path does not exist: {repo_path}") - return "" - - os.chdir(repo_path) - self.logger.log(f"šŸ” Changed to directory: {os.getcwd()}") - - try: - diff_content = "" - - # Check current git status first - try: - result = subprocess.run(['git', 'status', '--porcelain'], - capture_output=True, text=True, check=True, encoding='utf-8', errors='replace') - if result.stdout.strip(): - self.logger.log(f"šŸ” Git status shows changes: {result.stdout.strip()[:100]}...") - else: - self.logger.log("šŸ” Git status shows no uncommitted changes") - except Exception as e: - self.logger.log(f"āš ļø Could not check git status: {e}") - - # Check current branch - try: - result = subprocess.run(['git', 'branch', '--show-current'], - capture_output=True, text=True, check=True, encoding='utf-8', errors='replace') - current_branch = result.stdout.strip() - self.logger.log(f"šŸ” Current branch: {current_branch}") - except Exception as e: - self.logger.log(f"āš ļø Could not get current branch: {e}") - - # First, try to get diff from the current commit against main/master - try: - self.logger.log("šŸ” Trying: git diff main HEAD") - result = subprocess.run(['git', 'diff', 'main', 'HEAD'], - capture_output=True, text=True, check=True, encoding='utf-8', errors='replace') - diff_content = result.stdout - if diff_content: - self.logger.log(f"āœ… Retrieved git diff against main ({len(diff_content)} characters)") - else: - self.logger.log("šŸ” No diff found against main") - except subprocess.CalledProcessError as e: - self.logger.log(f"šŸ” git diff main HEAD failed: {e}") - try: - self.logger.log("šŸ” Trying: git diff master HEAD") - result = subprocess.run(['git', 'diff', 'master', 'HEAD'], - capture_output=True, text=True, check=True, encoding='utf-8', errors='replace') - diff_content = result.stdout - if diff_content: - self.logger.log(f"āœ… Retrieved git diff against master ({len(diff_content)} characters)") - else: - self.logger.log("šŸ” No diff found against master") - except subprocess.CalledProcessError as e: - self.logger.log(f"šŸ” git diff master HEAD failed: {e}") - - # If still no diff, try against previous commit (only if it exists) - if not diff_content: - try: - self.logger.log("šŸ” Trying: git rev-parse --verify HEAD~1") - subprocess.run(['git', 'rev-parse', '--verify', 'HEAD~1'], - capture_output=True, check=True, encoding='utf-8', errors='replace') - # If we get here, HEAD~1 exists - self.logger.log("šŸ” Trying: git diff HEAD~1 HEAD") - result = subprocess.run(['git', 'diff', 'HEAD~1', 'HEAD'], - capture_output=True, text=True, check=True, encoding='utf-8', errors='replace') - diff_content = result.stdout - if diff_content: - self.logger.log(f"āœ… Retrieved git diff against HEAD~1 ({len(diff_content)} characters)") - else: - self.logger.log("šŸ” No diff found against HEAD~1") - except subprocess.CalledProcessError as e: - self.logger.log(f"šŸ” HEAD~1 doesn't exist or diff failed: {e}") - - # If still no diff, try to get the diff of all changes in the current commit - if not diff_content: - try: - self.logger.log("šŸ” Trying: git show --format= HEAD") - result = subprocess.run(['git', 'show', '--format=', 'HEAD'], - capture_output=True, text=True, check=True, encoding='utf-8', errors='replace') - diff_content = result.stdout - if diff_content: - self.logger.log(f"āœ… Retrieved git show for HEAD commit ({len(diff_content)} characters)") - else: - self.logger.log("šŸ” No content from git show HEAD") - except subprocess.CalledProcessError as e: - self.logger.log(f"šŸ” git show HEAD failed: {e}") - - # If we have diff content, save it to a .diff file - if diff_content: - self._save_diff_to_file(diff_content, repo_path, branch_name) - else: - self.logger.log("āŒ No diff content found using any method") - - return diff_content - - finally: - os.chdir(original_dir) - self.logger.log(f"šŸ” Changed back to: {os.getcwd()}") - - except Exception as e: - self.logger.log(f"āŒ Error getting git diff from repository: {str(e)}") - import traceback - self.logger.log(f"āŒ Traceback: {traceback.format_exc()}") - return "" - - def _save_diff_to_file(self, diff_content: str, repo_path: str, branch_name: str) -> None: - """Save the diff content to a .diff file in the repository""" - try: - import os - from datetime import datetime - - # Create a filename with timestamp and branch name - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - safe_branch_name = branch_name.replace('/', '_').replace(':', '_') - diff_filename = f"changes_{safe_branch_name}_{timestamp}.diff" - diff_filepath = os.path.join(repo_path, diff_filename) - - # Write the diff content to file - with open(diff_filepath, 'w', encoding='utf-8') as f: - f.write(diff_content) - - self.logger.log(f"šŸ’¾ Saved diff to: {diff_filename}") - - except Exception as e: - self.logger.log(f"āŒ Error saving diff to file: {str(e)}") - - -def create_ai_provider(provider_name: str, api_key: str, logger: Logger, ollama_url: str = None, ollama_model: str = None) -> Optional[AIProvider]: - """Factory function to create AI provider instances""" - if provider_name.lower() == 'claude': - return ClaudeProvider(api_key, logger) - elif provider_name.lower() in ['chatgpt', 'openai', 'gpt']: - return ChatGPTProvider(api_key, logger) - elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']: - return GitHubCopilotProvider(api_key, logger) - elif provider_name.lower() == 'ollama': - # For Ollama, api_key is optional (can be empty string) - return OllamaProvider(api_key or "", logger, ollama_url, ollama_model) - else: - logger.log(f"āš ļø Unknown AI provider: {provider_name}") - return None - - -def get_detailed_python_environment_info() -> dict: - """Get detailed information about the current Python environment - - Returns: - dict: Environment information including venv status, Python version, etc. - """ - import sys - import os - - # Detect virtual environment - in_venv = (hasattr(sys, 'real_prefix') or - (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) or - os.environ.get('VIRTUAL_ENV') is not None) - - env_info = { - 'in_venv': in_venv, - 'python_version': f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - 'python_executable': sys.executable, - } - - if in_venv: - venv_path = os.environ.get('VIRTUAL_ENV', sys.prefix) - env_info['venv_name'] = os.path.basename(venv_path) - env_info['venv_path'] = venv_path - else: - env_info['venv_name'] = None - env_info['venv_path'] = None - - return env_info - - -def install_ai_packages_enhanced(packages: List[str], parent_window=None) -> bool: - """Enhanced AI provider package installation with better error handling - - Returns: - bool: True if installation successful or user declined, False if failed - """ - import subprocess - import sys - import os - - if not packages: - return True - - # Detect virtual environment - in_venv = (hasattr(sys, 'real_prefix') or - (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) or - os.environ.get('VIRTUAL_ENV') is not None) - - venv_info = "" - install_location = "" - - if in_venv: - venv_path = os.environ.get('VIRTUAL_ENV', sys.prefix) - venv_name = os.path.basename(venv_path) - install_location = f"virtual environment '{venv_name}'" - venv_info = f"\n🌐 Virtual environment detected: {venv_name}" - else: - install_location = "system-wide (may require administrator rights)" - venv_info = f"\nāš ļø No virtual environment detected - installing system-wide" - - # Create confirmation message - package_list = ', '.join(packages) - message = (f"The following packages are required for AI functionality:\n\n" - f"{package_list}\n\n" - f"Installation location: {install_location}" - f"{venv_info}\n\n" - f"Would you like to install them now?\n\n" - f"This will run: pip install {' '.join(packages)}") - - -def validate_ai_provider_setup(config: dict, parent_window=None) -> bool: - """Validate AI provider setup and offer to install missing modules - - Returns: - bool: True if setup is valid or user handled the issue - """ - ai_provider = config.get('AI_PROVIDER', '').lower() - - if not ai_provider or ai_provider == 'none': - return True # No AI provider selected, nothing to validate - - # Create a temporary AI manager to check modules - temp_manager = AIManager() - - # Check if modules are available - available, missing = temp_manager.check_ai_module_availability(ai_provider) - - if available: - return True # All modules available - - print(f"āš ļø AI Provider '{ai_provider}' selected but missing required packages: {', '.join(missing)}") - - # Offer to install missing packages using enhanced installer - success = install_ai_packages_enhanced(missing, parent_window) - - if success: - # Re-check availability after installation - available, still_missing = temp_manager.check_ai_module_availability(ai_provider) - if available: - print(f"āœ… AI Provider '{ai_provider}' is now ready to use") - return True - else: - print(f"āš ļø Some packages may still be missing: {', '.join(still_missing)}") - print("Please restart the application after installation completes") - return False - - return False - - -class OllamaProvider(AIProvider): - """Ollama AI provider for self-hosted models""" - - def __init__(self, api_key: str, logger: Logger, ollama_url: str = None, model: str = None): - super().__init__(api_key, logger) - self.ollama_url = ollama_url or "http://localhost:11434" - self.model = model or "llama2" - - # Normalize URL - if not self.ollama_url.startswith('http'): - self.ollama_url = f"http://{self.ollama_url}" - - def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: - """Make targeted changes using Ollama""" - - # Step 1: Try direct string replacement first - if old_text and old_text.strip() in file_content: - self.logger.log("āœ… Making direct string replacement (reference text found exactly)") - updated_content = file_content.replace(old_text.strip(), new_text.strip()) - if updated_content != file_content: - original_lines = file_content.split('\n') - updated_lines = updated_content.split('\n') - import difflib - diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm='')) - changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')]) - self.logger.log(f"āœ… Direct replacement successful ({changed_lines} lines changed)") - return updated_content - - # Step 2: Use Ollama to generate full document with targeted changes - self.logger.log(f"šŸ“ Using Ollama ({self.model}) to modify the document...") - return self._generate_updated_document(file_content, old_text, new_text, file_path, custom_instructions) - - def _generate_updated_document(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: - """Generate updated document content using Ollama""" - - try: - import requests - - # Build custom instructions text - if custom_instructions and custom_instructions.strip(): - custom_instructions_text = f""" -**Additional Custom Instructions:** -{custom_instructions.strip()} - -""" - else: - custom_instructions_text = "" - - # Handle case where new_text is empty or just guidance - if new_text and new_text.strip() and not new_text.strip().lower().startswith(' [!IMPORTANT] -> OUTPUT REQUIREMENTS: -> - Return ONLY the complete file content - no explanatory text, dialog, or commentary -> - Do NOT add any text before or after the file content -> - Do NOT wrap output in markdown code blocks (```), just return the raw content -> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...] -> - Every single line of the original document must be present in your response -> - Preserve all markdown formatting, links, and code blocks exactly -> - Only make changes that fulfill the specified request - -{custom_instructions_text} - -**Current File Content:** -``` -{file_content} -``` - -{guidance_text} - -Return the complete updated file content now (NO explanatory text):""" - - # Prepare request headers - headers = { - "Content-Type": "application/json" - } - if self.api_key: - headers["Authorization"] = f"Bearer {self.api_key}" - - # Prepare request payload - payload = { - "model": self.model, - "prompt": prompt, - "stream": False, - "options": { - "temperature": 0.3, # Lower temperature for more consistent output - "num_predict": -1, # Generate as many tokens as needed - } - } - - # Make request to Ollama - self.logger.log(f"šŸ”„ Sending request to Ollama at {self.ollama_url}...") - response = requests.post( - f"{self.ollama_url}/api/generate", - json=payload, - headers=headers, - timeout=300 # 5 minute timeout for large documents - ) - response.raise_for_status() - - result = response.json() - updated_content = result.get("response", "").strip() - - if not updated_content: - self.logger.log("āŒ Ollama returned empty response") - return None - - # Clean up response - updated_content = self._clean_ai_response(updated_content) - - # Validate that we got the full document back - original_line_count = len(file_content.split('\n')) - updated_line_count = len(updated_content.split('\n')) - - if updated_line_count < original_line_count * 0.5: # Less than 50% of original lines - self.logger.log(f"āš ļø Warning: Updated document seems truncated ({updated_line_count} vs {original_line_count} lines)") - self.logger.log("āŒ AI may have truncated the document - using fallback") - return None - - self.logger.log(f"āœ… Successfully generated updated document ({updated_line_count} lines)") - return updated_content - - except requests.exceptions.ConnectionError: - self.logger.log(f"āŒ Could not connect to Ollama server at {self.ollama_url}") - self.logger.log(" Make sure Ollama is running and the URL is correct") - return None - except requests.exceptions.Timeout: - self.logger.log("āŒ Request to Ollama server timed out") - return None - except requests.exceptions.HTTPError as e: - if e.response.status_code == 401: - self.logger.log("āŒ Authentication failed - check your Ollama API key") - elif e.response.status_code == 404: - self.logger.log(f"āŒ Model '{self.model}' not found on Ollama server") - self.logger.log(f" Use 'ollama pull {self.model}' to download it") - else: - self.logger.log(f"āŒ HTTP error from Ollama: {e}") - return None - except Exception as e: - self.logger.log(f"āŒ Error calling Ollama: {str(e)}") - import traceback - traceback.print_exc() - return None - - def _clean_ai_response(self, response: str) -> str: - """Clean up AI response by removing markdown code blocks and explanatory text""" - - # Remove markdown code blocks if present - if response.startswith('```'): - lines = response.split('\n') - # Remove first line if it's a code fence - if lines[0].startswith('```'): - lines = lines[1:] - # Remove last line if it's a code fence - if lines and lines[-1].strip() == '```': - lines = lines[:-1] - response = '\n'.join(lines) - - return response.strip() - - -# AI Providers availability flag - now always True since they're included -AI_PROVIDERS_AVAILABLE = True - - -class AIManager: - """Manages AI providers and module installations""" - - def __init__(self, logger=None): - self.logger = logger - self.last_diff_content = "" # Store the last generated diff content - - def log(self, message: str) -> None: - """Log a message with Unicode support""" - if self.logger: - self.logger.log(message) - else: - try: - print(message) - except UnicodeEncodeError: - # Fallback: replace Unicode emojis with ASCII equivalents - safe_message = message.replace('āœ…', '[SUCCESS]').replace('āŒ', '[ERROR]').replace('āš ļø', '[WARNING]').replace('šŸ“‹', '[INFO]').replace('šŸ“„', '[FILE]').replace('šŸ“', '[LOCATION]').replace('šŸ“', '[EDIT]') - print(safe_message) - - def check_ai_module_availability(self, provider_name: str) -> Tuple[bool, List[str]]: - """Check if AI provider modules are available and return missing packages - - Args: - provider_name: 'chatgpt', 'claude', 'anthropic', 'github-copilot', or 'ollama' - - Returns: - tuple: (all_available, missing_packages) - """ - missing_packages = [] - - # Common packages needed for AI providers - required_common = ['GitPython'] - - # Provider-specific packages - if provider_name.lower() == 'chatgpt': - required_packages = required_common + ['openai'] - elif provider_name.lower() in ['claude', 'anthropic']: - required_packages = required_common + ['anthropic'] - elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']: - required_packages = required_common + ['requests'] - elif provider_name.lower() == 'ollama': - required_packages = required_common + ['requests'] - else: - return True, [] # Unknown provider, assume no check needed - - for package in required_packages: - try: - if package == 'GitPython': - import git - elif package == 'openai': - import openai - elif package == 'anthropic': - import anthropic - elif package == 'requests': - import requests - except ImportError: - missing_packages.append(package) - - all_available = len(missing_packages) == 0 - return all_available, missing_packages - - def get_python_environment_info(self) -> dict: - """Get information about the current Python environment""" - return get_detailed_python_environment_info() - - def install_ai_packages(self, packages: List[str], parent_window=None) -> bool: - """Install AI packages using pip""" - try: - env_info = self.get_python_environment_info() - install_location = f"virtual environment '{env_info['venv_name']}'" if env_info['in_venv'] else "system-wide" - - # Show confirmation dialog - if parent_window: - install_choice = messagebox.askyesno( - "Install AI Packages", - f"šŸ Python {env_info['python_version']}\n" - f"šŸ“¦ Location: {install_location}\n\n" - f"The following packages will be installed:\n" - f"• {', '.join(packages)}\n\n" - f"This will run: pip install {' '.join(packages)}\n\n" - f"Continue with installation?", - parent=parent_window - ) - - if not install_choice: - return False - - self.log(f"Installing packages: {', '.join(packages)}") - self.log(f"Installation location: {install_location}") - - # Run pip install - cmd = [sys.executable, '-m', 'pip', 'install'] + packages - self.log(f"Running: {' '.join(cmd)}") - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - - if result.returncode == 0: - self.log("āœ… Installation completed successfully!") - if parent_window: - messagebox.showinfo( - "Installation Complete", - f"āœ… Successfully installed: {', '.join(packages)}\n\n" - f"Location: {install_location}", - parent=parent_window - ) - return True - else: - error_msg = f"āŒ Installation failed!\n\nError: {result.stderr}" - self.log(error_msg) - if parent_window: - messagebox.showerror("Installation Failed", error_msg, parent=parent_window) - return False - - except subprocess.TimeoutExpired: - error_msg = "āŒ Installation timed out (>5 minutes)" - self.log(error_msg) - if parent_window: - messagebox.showerror("Installation Timeout", error_msg, parent=parent_window) - return False - except Exception as e: - error_msg = f"āŒ Installation error: {str(e)}" - self.log(error_msg) - if parent_window: - messagebox.showerror("Installation Error", error_msg, parent=parent_window) - return False - - def check_and_install_ai_modules(self, provider_name: str, parent_window=None) -> bool: - """Check AI modules and offer to install if missing""" - if not provider_name or provider_name.lower() in ['none', '']: - return True - - if provider_name.lower() not in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot']: - return True - - # Check module availability - available, missing = self.check_ai_module_availability(provider_name) - - if available: - self.log(f"āœ… All required modules for {provider_name} are available") - return True - - # Modules are missing, offer to install - self.log(f"āš ļø Missing modules for {provider_name}: {', '.join(missing)}") - - return self.install_ai_packages(missing, parent_window) - - async def check_and_install_ai_modules_async(self, provider_name: str, page=None) -> bool: - """Async wrapper for check_and_install_ai_modules for Flet integration - - Args: - provider_name: AI provider name - page: Flet page instance for showing dialogs - - Returns: - bool: True if modules are available or successfully installed - """ - import asyncio - - # Run the sync method in a thread pool - result = await asyncio.to_thread( - self.check_and_install_ai_modules, - provider_name, - page - ) - return result - - def show_ai_modules_info(self, provider_name: str, parent_window=None) -> None: - """Show detailed AI modules information""" - try: - # Get environment information - env_info = self.get_python_environment_info() - env_status = f"šŸ Python {env_info['python_version']}" - - if env_info['in_venv']: - env_status += f" (venv: {env_info['venv_name']})" - else: - env_status += " (system-wide)" - - if not provider_name or provider_name.lower() in ['none', '']: - messagebox.showinfo("AI Modules Check", - f"{env_status}\n\n" - f"No AI provider selected.\n\n" - f"Available providers:\n" - f"• ChatGPT (requires 'openai' package)\n" - f"• Claude/Anthropic (requires 'anthropic' package)\n" - f"• GitHub Copilot (requires 'requests' package)\n" - f"• All require 'GitPython' package", - parent=parent_window) - return - - if provider_name.lower() not in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot']: - messagebox.showinfo("AI Modules Check", - f"AI provider '{provider_name}' is not recognized.\n\n" - f"Supported providers: ChatGPT, Claude/Anthropic, GitHub Copilot", - parent=parent_window) - return - - # Check module availability - available, missing = self.check_ai_module_availability(provider_name) - - if available: - messagebox.showinfo("AI Modules Status", - f"{env_status}\n\n" - f"āœ… All required modules for '{provider_name}' are installed!\n\n" - f"AI-assisted features are ready to use.", - parent=parent_window) - return - - # Modules are missing, show detailed info and offer to install - missing_list = '\n'.join(f"• {pkg}" for pkg in missing) - install_location = f"virtual environment '{env_info['venv_name']}'" if env_info['in_venv'] else "system-wide" - - install_choice = messagebox.askyesno("Missing AI Modules", - f"{env_status}\n\n" - f"AI provider '{provider_name}' requires the following packages:\n\n" - f"{missing_list}\n\n" - f"Installation location: {install_location}\n\n" - f"Would you like to install them now?\n\n" - f"This will run: pip install {' '.join(missing)}", - parent=parent_window) - - if install_choice: - success = self.install_ai_packages(missing, parent_window) - - if success: - messagebox.showinfo("Installation Complete", - f"āœ… AI modules installed successfully!\n\n" - f"Provider: {provider_name}\n" - f"Location: {install_location}\n\n" - f"AI-assisted features are now ready to use.", - parent=parent_window) - else: - messagebox.showerror("Installation Failed", - f"āŒ Failed to install AI modules.\n\n" - f"Please try installing manually:\n" - f"pip install {' '.join(missing)}", - parent=parent_window) - else: - messagebox.showinfo("Installation Skipped", - f"AI modules were not installed.\n\n" - f"You can install them later with:\n" - f"pip install {' '.join(missing)}", - parent=parent_window) - - except Exception as e: - if parent_window: - messagebox.showerror("Error", - f"Error checking AI modules: {str(e)}", - parent=parent_window) - if self.logger: - self.logger.log(f"Error in AI modules check: {str(e)}") - - def create_ai_provider(self, provider_name: str, api_key: str, ollama_url: str = None, ollama_model: str = None): - """Create an AI provider instance""" - if not AI_PROVIDERS_AVAILABLE: - return None - - try: - ai_logger = Logger(self.log) - return create_ai_provider(provider_name, api_key, ai_logger, ollama_url, ollama_model) - except Exception as e: - self.log(f"Error creating AI provider: {e}") - return None - - def create_local_git_manager(self, github_token: str): - """Create a LocalGitManager instance""" - if not AI_PROVIDERS_AVAILABLE: - return None - - try: - ai_logger = Logger(self.log) - return LocalGitManager(ai_logger, github_token) - except Exception as e: - self.log(f"Error creating LocalGitManager: {e}") - return None - - def get_last_diff_content(self) -> str: - """Get the last generated diff content for display in the UI""" - return self.last_diff_content - - def clear_diff_content(self): - """Clear the stored diff content""" - self.last_diff_content = "" - - def generate_response(self, prompt: str, provider_name: str, config: dict) -> str: - """Generate a text response from an AI provider - - Args: - prompt: The prompt/question to send to the AI - provider_name: Name of the AI provider ('chatgpt', 'claude', 'ollama', etc.) - config: Configuration dictionary containing API keys and settings - - Returns: - str: The AI-generated response - """ - try: - provider_name = provider_name.lower() - - # OpenAI/ChatGPT - if provider_name in ['chatgpt', 'openai', 'gpt']: - api_key = config.get('OPENAI_API_KEY', '') - if not api_key: - return "Error: OpenAI API key not configured" - - try: - import openai - client = openai.OpenAI(api_key=api_key) - - response = client.chat.completions.create( - model=config.get('OPENAI_MODEL', 'gpt-4'), - messages=[ - {"role": "system", "content": "You are a helpful assistant that analyzes GitHub pull requests and issues."}, - {"role": "user", "content": prompt} - ], - max_tokens=2000, - temperature=0.7 - ) - - return response.choices[0].message.content.strip() - - except Exception as e: - self.log(f"Error calling OpenAI API: {e}") - return f"Error calling OpenAI API: {str(e)}" - - # Anthropic/Claude - 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', '') - if not api_key: - return "Error: Claude API key not configured (tried both CLAUDE_API_KEY and ANTHROPIC_API_KEY)" - - try: - import anthropic - client = anthropic.Anthropic(api_key=api_key) - - response = client.messages.create( - model=config.get('ANTHROPIC_MODEL', 'claude-sonnet-4-5'), - max_tokens=2000, - messages=[ - {"role": "user", "content": prompt} - ] - ) - - return response.content[0].text.strip() - - except Exception as e: - self.log(f"Error calling Anthropic API: {e}") - return f"Error calling Anthropic API: {str(e)}" - - # Ollama - elif provider_name == 'ollama': - ollama_url = config.get('OLLAMA_URL', 'http://localhost:11434') - ollama_model = config.get('OLLAMA_MODEL', 'llama2') - - try: - import requests - - # Normalize URL - if not ollama_url.startswith('http'): - ollama_url = f"http://{ollama_url}" - - # Remove trailing slash - ollama_url = ollama_url.rstrip('/') - - api_url = f"{ollama_url}/api/generate" - - payload = { - "model": ollama_model, - "prompt": prompt, - "stream": False - } - - response = requests.post(api_url, json=payload, timeout=120) - response.raise_for_status() - - result = response.json() - return result.get('response', '').strip() - - except Exception as e: - self.log(f"Error calling Ollama API: {e}") - return f"Error calling Ollama API: {str(e)}" - - else: - return f"Error: Unknown AI provider '{provider_name}'" - - except Exception as e: - self.log(f"Error in generate_response: {e}") - return f"Error generating response: {str(e)}" \ No newline at end of file diff --git a/src/app_components/assets/flow-diagram.png b/src/app_components/assets/flow-diagram.png deleted file mode 100644 index f3aa649..0000000 Binary files a/src/app_components/assets/flow-diagram.png and /dev/null differ diff --git a/src/app_components/assets/github_pulse_img.png b/src/app_components/assets/github_pulse_img.png deleted file mode 100644 index 8b5f930..0000000 Binary files a/src/app_components/assets/github_pulse_img.png and /dev/null differ diff --git a/src/app_components/assets/pulse_logo_gray_no_bkg.png b/src/app_components/assets/pulse_logo_gray_no_bkg.png deleted file mode 100644 index 8d03e89..0000000 Binary files a/src/app_components/assets/pulse_logo_gray_no_bkg.png and /dev/null differ diff --git a/src/app_components/assets/pulse_logo_white_no_bkg.png b/src/app_components/assets/pulse_logo_white_no_bkg.png deleted file mode 100644 index 6159727..0000000 Binary files a/src/app_components/assets/pulse_logo_white_no_bkg.png and /dev/null differ diff --git a/src/app_components/assets/pulse_logo_white_no_bkg_github.png b/src/app_components/assets/pulse_logo_white_no_bkg_github.png deleted file mode 100644 index a525242..0000000 Binary files a/src/app_components/assets/pulse_logo_white_no_bkg_github.png and /dev/null differ diff --git a/src/app_components/assets/pulse_logo_white_w_black_bkg.png b/src/app_components/assets/pulse_logo_white_w_black_bkg.png deleted file mode 100644 index 1e6b5c2..0000000 Binary files a/src/app_components/assets/pulse_logo_white_w_black_bkg.png and /dev/null differ diff --git a/src/app_components/cache_manager.py b/src/app_components/cache_manager.py deleted file mode 100644 index 077412c..0000000 --- a/src/app_components/cache_manager.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -Cache Manager for GitHub PRs and Issues -Stores fetched items in temporary cache to avoid reloading on every app start -""" - -import json -import os -import tempfile -import time -from pathlib import Path -from typing import List, Dict, Any, Optional -from hashlib import md5 - - -class CacheManager: - """Manages caching of GitHub PRs and Issues""" - - def __init__(self, cache_duration_hours: int = 24): - """ - Initialize cache manager - - Args: - cache_duration_hours: How long cache is valid (default 24 hours) - """ - self.cache_duration_seconds = cache_duration_hours * 3600 - self.cache_dir = Path(tempfile.gettempdir()) / "github_pulse_cache" - self.cache_dir.mkdir(exist_ok=True) - - def _get_cache_key(self, source_type: str, identifier: str) -> str: - """Generate cache key from source type and identifier""" - # Use MD5 hash to create safe filename - key_str = f"{source_type}_{identifier}" - return md5(key_str.encode()).hexdigest() - - def _get_cache_path(self, cache_key: str) -> Path: - """Get full path to cache file""" - return self.cache_dir / f"{cache_key}.json" - - def is_cache_valid(self, source_type: str, identifier: str) -> bool: - """Check if cache exists and is still valid""" - cache_key = self._get_cache_key(source_type, identifier) - cache_path = self._get_cache_path(cache_key) - - if not cache_path.exists(): - return False - - # Check if cache has expired - file_age = time.time() - cache_path.stat().st_mtime - return file_age < self.cache_duration_seconds - - def load_from_cache(self, source_type: str, identifier: str) -> Optional[List[Dict[str, Any]]]: - """ - Load GitHub items from cache - - Args: - source_type: 'github_prs', 'github_issues', 'target_prs', 'fork_prs', etc. - identifier: repository identifier or config hash - - Returns: - List of items if cache is valid, None otherwise - """ - if not self.is_cache_valid(source_type, identifier): - return None - - cache_key = self._get_cache_key(source_type, identifier) - cache_path = self._get_cache_path(cache_key) - - try: - with open(cache_path, 'r', encoding='utf-8') as f: - cache_data = json.load(f) - - # Validate cache structure - if 'timestamp' not in cache_data or 'items' not in cache_data: - return None - - return cache_data['items'] - - except Exception as e: - print(f"Error loading cache: {e}") - return None - - def save_to_cache(self, source_type: str, identifier: str, items: List[Dict[str, Any]]) -> bool: - """ - Save GitHub items to cache - - Args: - source_type: 'github_prs', 'github_issues', 'target_prs', 'fork_prs', etc. - identifier: repository identifier or config hash - items: List of items to cache (PRs or Issues) - - Returns: - True if successful, False otherwise - """ - cache_key = self._get_cache_key(source_type, identifier) - cache_path = self._get_cache_path(cache_key) - - try: - cache_data = { - 'timestamp': time.time(), - 'source_type': source_type, - 'identifier': identifier, - 'items': items - } - - with open(cache_path, 'w', encoding='utf-8') as f: - json.dump(cache_data, f, indent=2, ensure_ascii=False) - - return True - - except Exception as e: - print(f"Error saving cache: {e}") - return False - - def invalidate_cache(self, source_type: str = None, identifier: str = None): - """ - Invalidate (delete) cache - - Args: - source_type: If specified, only invalidate this source type - identifier: If specified, only invalidate this specific cache - """ - if source_type and identifier: - # Invalidate specific cache - cache_key = self._get_cache_key(source_type, identifier) - cache_path = self._get_cache_path(cache_key) - if cache_path.exists(): - cache_path.unlink() - elif source_type: - # Invalidate all caches for this source type - for cache_file in self.cache_dir.glob("*.json"): - try: - with open(cache_file, 'r', encoding='utf-8') as f: - cache_data = json.load(f) - if cache_data.get('source_type') == source_type: - cache_file.unlink() - except: - pass - else: - # Invalidate all caches - for cache_file in self.cache_dir.glob("*.json"): - cache_file.unlink() - - def get_cache_info(self) -> Dict[str, Any]: - """Get information about cached items""" - cache_files = list(self.cache_dir.glob("*.json")) - - info = { - 'cache_dir': str(self.cache_dir), - 'total_files': len(cache_files), - 'total_size_bytes': sum(f.stat().st_size for f in cache_files), - 'caches': [] - } - - for cache_file in cache_files: - try: - with open(cache_file, 'r', encoding='utf-8') as f: - cache_data = json.load(f) - - file_age = time.time() - cache_file.stat().st_mtime - is_valid = file_age < self.cache_duration_seconds - - info['caches'].append({ - 'source_type': cache_data.get('source_type', 'unknown'), - 'item_count': len(cache_data.get('items', [])), - 'age_hours': round(file_age / 3600, 1), - 'is_valid': is_valid, - 'size_kb': round(cache_file.stat().st_size / 1024, 1) - }) - except: - pass - - return info - - def cleanup_expired(self): - """Remove expired cache files""" - current_time = time.time() - removed_count = 0 - - for cache_file in self.cache_dir.glob("*.json"): - file_age = current_time - cache_file.stat().st_mtime - if file_age >= self.cache_duration_seconds: - cache_file.unlink() - removed_count += 1 - - return removed_count diff --git a/src/app_components/config_manager.py b/src/app_components/config_manager.py deleted file mode 100644 index d572328..0000000 --- a/src/app_components/config_manager.py +++ /dev/null @@ -1,246 +0,0 @@ -""" -Configuration Manager -Wrapper around SettingsManager for backward compatibility. -Now uses config.json + keyring instead of .env files. -""" - -import os -import json -from typing import Dict, Any, Optional -from pathlib import Path -from .settings_manager import SettingsManager - - -class ConfigManager: - """ - Manages application configuration using the new SettingsManager. - - Provides backward compatibility with old .env-based code while - using the modern config.json + keyring system underneath. - """ - - def __init__(self): - """Initialize with SettingsManager backend""" - # Initialize the modern settings system - self._settings = SettingsManager() - - # Check if .env exists and offer migration - env_path = Path('.env') - if env_path.exists() and not Path('application/config.json').exists(): - print("\n" + "="*60) - print("NOTICE: Legacy .env file detected!") - print("="*60) - print("Your app now uses a modern settings system with:") - print(" āœ“ Secure API key storage (Windows Credential Manager)") - print(" āœ“ Live settings updates (no restart needed)") - print(" āœ“ Better configuration management") - print() - print("Migrating settings from .env to new system...") - print() - - if self._settings.migrate_from_env(env_path): - print("āœ“ Migration successful!") - print(f" - Secrets → System keyring") - print(f" - Settings → {self._settings.config_file}") - print() - print("Your .env file is kept as backup.") - print("You can delete it once you verify everything works.") - else: - print("āœ— Migration failed. Using .env as fallback.") - print("="*60 + "\n") - - # Load configuration - self.config = self._settings.get_all() - - # Auto-default GITHUB_TOKEN to GITHUB_PAT if needed - self._apply_token_defaults() - - # Show configuration status - self._print_config_status() - - def _apply_token_defaults(self): - """Auto-default GITHUB_TOKEN to GITHUB_PAT if GITHUB_TOKEN is empty""" - github_token = self.config.get('GITHUB_TOKEN', '').strip() if self.config.get('GITHUB_TOKEN') else '' - github_pat = self.config.get('GITHUB_PAT', '').strip() if self.config.get('GITHUB_PAT') else '' - - if not github_token and github_pat: - self.config['GITHUB_TOKEN'] = github_pat - self._settings.set('GITHUB_TOKEN', github_pat, save=False) - - def _print_config_status(self): - """Print configuration load status""" - loaded_keys = [] - for key, value in self.config.items(): - if value and str(value).strip(): - # Don't show actual secret values - if key in SettingsManager.SECRET_KEYS: - loaded_keys.append(f"{key}: loaded") - else: - loaded_keys.append(f"{key}: loaded") - - if loaded_keys: - print(f"Configuration status: {', '.join(loaded_keys)}") - else: - print("No configuration values loaded - using defaults") - - def load_configuration(self) -> Dict[str, Any]: - """ - Load configuration from new system (config.json + keyring). - - Returns: - Dictionary of all settings - """ - self.config = self._settings.load() - self._apply_token_defaults() - return self.config - - def save_configuration(self, config_values: Dict[str, Any]) -> bool: - """ - Save configuration using new system. - - No restart required - changes apply immediately! - - Args: - config_values: Settings to save - - Returns: - True if successful - """ - # Save using new system - success = self._settings.save(config_values) - - if success: - # Reload to get updated values - self.config = self._settings.get_all() - self._apply_token_defaults() - print(f"Configuration saved to {self._settings.config_file}") - print("Settings updated (no restart needed!)") - else: - print("Failed to save configuration") - - return success - - def get_config(self) -> Dict[str, Any]: - """ - Get current configuration with automatic GITHUB_TOKEN defaulting. - - Returns: - Dictionary of all settings - """ - config = self.config.copy() - - # Auto-default GITHUB_TOKEN to GITHUB_PAT if needed - github_token = config.get('GITHUB_TOKEN', '').strip() if config.get('GITHUB_TOKEN') else '' - github_pat = config.get('GITHUB_PAT', '').strip() if config.get('GITHUB_PAT') else '' - - if not github_token and github_pat: - config['GITHUB_TOKEN'] = github_pat - - return config - - def get_value(self, key: str, default: Any = None) -> Any: - """ - Get a specific configuration value. - - Args: - key: Setting key - default: Default value if not found - - Returns: - Setting value or default - """ - return self._settings.get(key, default) - - def get(self, key: str, default: Any = None) -> Any: - """ - Get a specific configuration value (dictionary-like interface). - - Args: - key: Setting key - default: Default value if not found - - Returns: - Setting value or default - """ - return self._settings.get(key, default) - - def set_value(self, key: str, value: Any) -> None: - """ - Set a specific configuration value. - - Args: - key: Setting key - value: New value - """ - self._settings.set(key, value) - self.config[key] = value - - def register_listener(self, callback): - """ - Register a callback for settings changes (live updates). - - The callback will be called with (key, new_value) when a setting changes. - - Args: - callback: Function to call on settings change - - Example: - def on_settings_changed(key, value): - if key == 'THEME_MODE': - # Update theme immediately - page.theme_mode = ft.ThemeMode.DARK if value == 'dark' else ft.ThemeMode.LIGHT - page.update() - - config_manager.register_listener(on_settings_changed) - """ - self._settings.register_listener(callback) - - def unregister_listener(self, callback): - """ - Unregister a settings change callback. - - Args: - callback: Function to remove from listeners - """ - self._settings.unregister_listener(callback) - - # Legacy methods for PR counter (unchanged) - - def get_pr_counter_file(self) -> str: - """Get the path to the PR counter file""" - script_dir = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(script_dir, '..', '.pr_counter.json') - - def load_pr_counter(self) -> Dict[str, int]: - """Load the PR counter from file""" - counter_file = self.get_pr_counter_file() - if os.path.exists(counter_file): - try: - with open(counter_file, 'r', encoding='utf-8') as f: - return json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - pass - return {'count': 0} - - def save_pr_counter(self, counter: Dict[str, int]) -> bool: - """Save the PR counter to file""" - counter_file = self.get_pr_counter_file() - try: - with open(counter_file, 'w', encoding='utf-8') as f: - json.dump(counter, f, indent=2) - return True - except Exception as e: - print(f"Error saving PR counter: {e}") - return False - - def increment_pr_counter(self) -> int: - """Increment and return the PR counter""" - counter = self.load_pr_counter() - counter['count'] = counter.get('count', 0) + 1 - self.save_pr_counter(counter) - return counter['count'] - - def get_pr_counter(self) -> int: - """Get the current PR counter value""" - counter = self.load_pr_counter() - return counter.get('count', 0) diff --git a/src/app_components/github_api.py b/src/app_components/github_api.py deleted file mode 100644 index a3ab660..0000000 --- a/src/app_components/github_api.py +++ /dev/null @@ -1,985 +0,0 @@ -""" -GitHub API Manager -Handles GitHub GraphQL operations, PR/Issue creation, and Copilot interactions -""" - -import base64 -import difflib -import json -import requests -from typing import Optional, Tuple, Dict, Any, List -from urllib.parse import urlparse - -# Constants -GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql" -USER_AGENT = "github-automation-tool/1.0" - - -class GitHubGQL: - """GitHub GraphQL API client for creating issues, PRs, and managing assignments""" - - def __init__(self, token: str, logger=None, dry_run: bool = False): - self.token = token - self.logger = logger - self.dry_run = dry_run - - def log(self, message: str) -> None: - """Log a message""" - if self.logger: - self.logger.log(message) - else: - print(message) - - def _headers(self): - """Get headers for GitHub API requests""" - return { - "Authorization": f"Bearer {self.token}", - "User-Agent": USER_AGENT, - "Content-Type": "application/json" - } - - def run(self, query: str, variables: dict | None = None) -> dict: - """Execute a GraphQL query""" - payload = {"query": query, "variables": variables or {}} - - if self.dry_run: - self.log("[DRY-RUN] Would POST GraphQL payload:") - pretty = json.dumps(payload, indent=2) - self.log(pretty) - return {"dryRun": True, "data": None} - - try: - resp = requests.post(GITHUB_GRAPHQL_ENDPOINT, headers=self._headers(), json=payload, timeout=60) - if resp.status_code != 200: - raise RuntimeError(f"GraphQL HTTP {resp.status_code}: {resp.text}") - - data = resp.json() - if "errors" in data and data["errors"]: - raise RuntimeError(f"GraphQL errors: {json.dumps(data['errors'], indent=2)}") - - return data - except requests.RequestException as e: - raise RuntimeError(f"Request failed: {str(e)}") - - def _make_rest_request(self, method: str, url: str, data: Dict[str, Any] = None) -> Dict[str, Any]: - """Make a REST API request to GitHub""" - headers = { - "Authorization": f"Bearer {self.token}", - "Accept": "application/vnd.github+json", - "User-Agent": USER_AGENT - } - - if self.dry_run: - self.log(f"[DRY-RUN] Would make {method} request to: {url}") - return {"number": 123, "html_url": "https://github.com/example/repo/pull/123"} - - response = requests.request(method, url, headers=headers, json=data, timeout=30) - response.raise_for_status() - - return response.json() - - def get_repo_id(self, owner: str, name: str) -> str: - """Get GitHub repository ID""" - self.log(f"Fetching repositoryId for {owner}/{name}...") - query = """ - query($owner:String!, $name:String!) { - repository(owner:$owner, name:$name) { - id - url - } - } - """ - data = self.run(query, {"owner": owner, "name": name}) - - if data.get("dryRun"): - return "DRY_RUN_REPO_ID" - - repo = data["data"]["repository"] - if not repo: - raise RuntimeError(f"Repository {owner}/{name} not found or token lacks access.") - - self.log(f"Repository ID: {repo['id']} ({repo['url']})") - return repo["id"] - - def get_copilot_actor_id(self, owner: str, name: str) -> tuple[str | None, str | None]: - """Find Copilot actor ID for assignment""" - self.log("Querying suggestedActors for CAN_BE_ASSIGNED...") - query = """ - query($owner:String!, $name:String!) { - repository(owner:$owner, name:$name) { - suggestedActors(capabilities:[CAN_BE_ASSIGNED], first:100) { - nodes { - login - __typename - ... on Bot { id } - ... on User { id } - } - } - } - } - """ - data = self.run(query, {"owner": owner, "name": name}) - - if data.get("dryRun"): - return ("DRY_RUN_ACTOR_ID", "copilot-swe-agent") - - nodes = data["data"]["repository"]["suggestedActors"]["nodes"] - if not nodes: - self.log("No suggestedActors returned.") - return (None, None) - - # Log all available actors for debugging - self.log(f"Available assignable actors ({len(nodes)}):") - for node in nodes: - self.log(f" - {node.get('login', 'N/A')} ({node.get('__typename', 'N/A')}) ID: {node.get('id', 'N/A')}") - - # Prefer known Copilot logins - preferred = ("copilot-swe-agent", "copilot", "github-copilot", "github-advanced-security") - chosen = None - - # First, try exact matches - for candidate in nodes: - login = candidate.get("login", "").lower() - if login in preferred: - chosen = candidate - break - - # If no exact match, try partial matches - if not chosen: - for candidate in nodes: - login = candidate.get("login", "").lower() - if "copilot" in login: - chosen = candidate - break - - if not chosen: - self.log("Copilot not found in suggestedActors list.") - self.log("Available actors: " + ", ".join([n.get("login", "N/A") for n in nodes])) - return (None, None) - - login = chosen["login"] - actor_id = chosen.get("id") - - if not actor_id: - self.log(f"Warning: No actor ID found for {login}") - return (None, None) - - self.log(f"Found assignable Copilot actor: {login} (id={actor_id})") - return (actor_id, login) - - def create_issue(self, repository_id: str, title: str, body: str) -> tuple[str, str, int]: - """Create a GitHub issue""" - self.log("Creating issue with createIssue mutation...") - mutation = """ - mutation($repositoryId:ID!, $title:String!, $body:String!) { - createIssue(input:{repositoryId:$repositoryId, title:$title, body:$body}) { - issue { - id - url - number - title - } - } - } - """ - data = self.run(mutation, {"repositoryId": repository_id, "title": title, "body": body}) - - if data.get("dryRun"): - return ("DRY_RUN_ISSUE_ID", "https://github.com/owner/repo/issues/123", 123) - - issue = data["data"]["createIssue"]["issue"] - self.log(f"Issue created: {issue['url']} (#{issue['number']})") - return (issue["id"], issue["url"], issue["number"]) - - def create_branch_from_main(self, owner: str, repo: str, branch_name: str) -> bool: - """Create a new branch from the main branch""" - self.log(f"Creating branch '{branch_name}' in {owner}/{repo}") - - try: - # Get the SHA of the main branch - main_ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/ref/heads/main" - main_ref_response = self._make_rest_request("GET", main_ref_url) - main_sha = main_ref_response["object"]["sha"] - - self.log(f"Main branch SHA: {main_sha}") - - # Create new branch - new_ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/refs" - new_ref_data = { - "ref": f"refs/heads/{branch_name}", - "sha": main_sha - } - - if self.dry_run: - self.log(f"🧪 DRY RUN: Would create branch '{branch_name}' from main ({main_sha})") - return True - - self._make_rest_request("POST", new_ref_url, new_ref_data) - self.log(f"āœ… Branch '{branch_name}' created successfully") - return True - - except Exception as e: - self.log(f"āŒ Failed to create branch: {str(e)}") - return False - - def get_user_forks(self, include_org_repos: bool = True) -> List[str]: - """Get list of user's forked repositories""" - self.log("Fetching user's forked repositories...") - - if self.dry_run: - # Return sample data for dry run - return [ - "username/repo_name", - ] - - try: - forks = [] - page = 1 - per_page = 100 - - while page <= 5: # Limit to 5 pages to avoid long waits - url = f"https://api.github.com/user/repos?type=forks&per_page={per_page}&page={page}" - - response = self._make_rest_request("GET", url) - repos = response if isinstance(response, list) else response.get('data', []) - - if not repos: - break - - for repo in repos: - if repo.get('fork', False): - forks.append(f"{repo['owner']['login']}/{repo['name']}") - - if len(repos) < per_page: - break - - page += 1 - - self.log(f"Found {len(forks)} forked repositories") - return forks - - except Exception as e: - self.log(f"āŒ Failed to fetch user forks: {str(e)}") - return [] - - def get_authenticated_user(self) -> Dict[str, Any]: - """Get authenticated user information""" - if self.dry_run: - return {"login": "dry-run-user", "name": "Dry Run User"} - - try: - return self._make_rest_request("GET", "https://api.github.com/user") - except Exception as e: - self.log(f"āŒ Failed to get user info: {str(e)}") - return {} - - def fork_repository(self, owner: str, repo: str, target_org: str = None) -> tuple[str, str]: - """Fork a repository to the authenticated user's account or specified organization""" - self.log(f"Forking repository {owner}/{repo}") - - fork_url = f"https://api.github.com/repos/{owner}/{repo}/forks" - fork_data = {} - - if target_org: - fork_data["organization"] = target_org - - if self.dry_run: - # Get authenticated user for dry run - user_url = "https://api.github.com/user" - try: - user_data = self._make_rest_request("GET", user_url) - fork_owner = target_org if target_org else user_data["login"] - self.log(f"🧪 DRY RUN: Would fork {owner}/{repo} to {fork_owner}/{repo}") - return fork_owner, repo - except: - self.log(f"🧪 DRY RUN: Would fork {owner}/{repo}") - return "dry-run-user", repo - - try: - fork_response = self._make_rest_request("POST", fork_url, fork_data) - fork_owner = fork_response["owner"]["login"] - fork_name = fork_response["name"] - - self.log(f"āœ… Repository forked to {fork_owner}/{fork_name}") - return fork_owner, fork_name - - except Exception as e: - self.log(f"āŒ Failed to fork repository: {str(e)}") - raise - - def check_repository_exists(self, owner: str, repo: str) -> bool: - """Check if a repository exists and is accessible""" - try: - url = f"https://api.github.com/repos/{owner}/{repo}" - response = self._make_rest_request("GET", url) - return bool(response.get('id')) - except: - return False - - def find_matching_repositories(self, target_repo: str, fork_repo: str) -> Dict[str, List[str]]: - """Find matching repositories to suggest alternatives for mismatched repos""" - self.log(f"Finding matching repositories for target: {target_repo}, fork: {fork_repo}") - - if self.dry_run: - return { - "target_alternatives": ["username/target_repo_name"], - "fork_alternatives": ["username/fork_repo_name"] - } - - try: - target_owner, target_name = target_repo.split('/', 1) if '/' in target_repo else ("", target_repo) - fork_owner, fork_name = fork_repo.split('/', 1) if '/' in fork_repo else ("", fork_repo) - - target_alternatives = [] - fork_alternatives = [] - - # Get authenticated user info - user_info = self.get_authenticated_user() - user_login = user_info.get('login', '') - - # Search for repositories with similar names - search_terms = [target_name, fork_name] - for term in search_terms: - if term: - # Clean up the search term (remove common suffixes) - clean_term = term.replace('-docs', '').replace('-pr', '').replace('_', ' ') - - # Search for repositories - search_url = f"https://api.github.com/search/repositories?q={clean_term}&per_page=20" - try: - search_response = self._make_rest_request("GET", search_url) - repositories = search_response.get('items', []) - - for repo_data in repositories: - repo_full_name = repo_data['full_name'] - repo_owner = repo_data['owner']['login'] - - # Check if this is a potential target alternative - if (repo_owner == target_owner and - repo_data['name'] != target_name and - repo_full_name not in target_alternatives): - target_alternatives.append(repo_full_name) - - # Check if this is a potential fork alternative - if (repo_owner == user_login and - repo_data['name'] != fork_name and - repo_data.get('fork', False) and - repo_full_name not in fork_alternatives): - fork_alternatives.append(repo_full_name) - - except Exception as e: - self.log(f"āŒ Search failed for term '{term}': {str(e)}") - - return { - "target_alternatives": target_alternatives[:5], # Limit to 5 suggestions - "fork_alternatives": fork_alternatives[:5] - } - - except Exception as e: - self.log(f"āŒ Failed to find matching repositories: {str(e)}") - return {"target_alternatives": [], "fork_alternatives": []} - - def make_documentation_change(self, owner: str, repo: str, branch_name: str, file_path: str, - old_text: str, new_text: str, commit_message: str) -> bool: - """Make actual documentation changes to a file in the repository - - This fetches the file, makes the text replacement, and commits it to the branch. - - Returns True if successful, False otherwise. - """ - if self.dry_run: - self.log(f"[DRY-RUN] Would update {file_path} in branch {branch_name}") - self.log(f"[DRY-RUN] Replace: {old_text[:50]}...") - self.log(f"[DRY-RUN] With: {new_text[:50]}...") - return True - - try: - rest_headers = { - "Authorization": f"Bearer {self.token}", - "Accept": "application/vnd.github+json", - "User-Agent": USER_AGENT - } - - # 1. Get the current file content from the branch - self.log(f"Fetching file: {file_path}") - file_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={branch_name}" - resp = requests.get(file_url, headers=rest_headers, timeout=30) - - if resp.status_code == 404: - self.log(f"āŒ File not found: {file_path}") - self.log(f" The file path might be incorrect or the file doesn't exist") - return False - - resp.raise_for_status() - file_data = resp.json() - - # Decode the file content - current_content = base64.b64decode(file_data["content"]).decode('utf-8') - file_sha = file_data["sha"] - - self.log(f"āœ… File retrieved ({len(current_content)} bytes)") - - # Detect line ending style to preserve it - line_ending = '\r\n' if '\r\n' in current_content else '\n' - self.log(f"šŸ“ Detected line endings: {'CRLF' if line_ending == '\\r\\n' else 'LF'}") - - # Normalize everything to LF for consistent processing - normalized_content = current_content.replace('\r\n', '\n') - normalized_old = old_text.replace('\r\n', '\n') - normalized_new = new_text.replace('\r\n', '\n') - - # 2. Make the text replacement - if normalized_old not in normalized_content: - self.log(f"āš ļø Warning: Could not find exact text to replace in {file_path}") - self.log(f" Searching for similar text...") - - # Try to find similar text (case-insensitive, whitespace-flexible) - lines = normalized_content.split('\n') - old_lines = normalized_old.split('\n') - - # Find the best matching sequence - matcher = difflib.SequenceMatcher(None, old_lines, lines) - match = matcher.find_longest_match(0, len(old_lines), 0, len(lines)) - - if match.size > len(old_lines) * 0.7: # If we find 70% match - self.log(f" Found similar text at line {match.b + 1}") - self.log(f" Making best-effort replacement...") - # This is a simplified approach - in production you'd want more sophisticated matching - else: - self.log(f"āŒ Could not find text to replace. The document may have changed.") - self.log(f" Creating PR with instructions instead...") - return False - - # Replace the text (using normalized versions) - updated_content = normalized_content.replace(normalized_old, normalized_new) - - if updated_content == normalized_content: - self.log(f"āš ļø No changes made - text might not exist in file") - return False - - self.log(f"āœ… Text replacement successful") - - # Restore original line endings - if line_ending == '\r\n': - updated_content = updated_content.replace('\n', '\r\n') - self.log(f"āœ… Restored CRLF line endings") - - # 3. Commit the updated file - self.log(f"Committing changes to {file_path}...") - encoded_content = base64.b64encode(updated_content.encode('utf-8')).decode() - - update_payload = { - "message": commit_message, - "content": encoded_content, - "sha": file_sha, - "branch": branch_name - } - - update_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}" - resp = requests.put(update_url, headers=rest_headers, json=update_payload, timeout=30) - resp.raise_for_status() - - self.log(f"āœ… Changes committed to branch {branch_name}") - return True - - except requests.HTTPError as e: - self.log(f"āŒ HTTP Error making changes: {e}") - if e.response.status_code == 403: - self.log(" Permission denied - token doesn't have write access") - elif e.response.status_code == 404: - self.log(f" File not found: {file_path}") - return False - except Exception as e: - self.log(f"āŒ Error making changes: {str(e)}") - return False - - def create_cross_repo_pull_request(self, source_owner: str, source_repo: str, target_owner: str, target_repo: str, - title: str, body: str, head_ref: str, base_ref: str = "main") -> tuple[str, str, int]: - """Create a pull request from source repo to target repo""" - self.log(f"Creating cross-repository PR from {source_owner}/{source_repo}:{head_ref} to {target_owner}/{target_repo}:{base_ref}") - - # Get target repository ID - target_repo_id = self.get_repo_id(target_owner, target_repo) - - # Format the head reference for cross-repo PR - head_ref_full = f"{source_owner}:{head_ref}" - - mutation = """ - mutation($repositoryId:ID!, $title:String!, $body:String!, $headRefName:String!, $baseRefName:String!) { - createPullRequest(input:{ - repositoryId:$repositoryId, - title:$title, - body:$body, - headRefName:$headRefName, - baseRefName:$baseRefName - }) { - pullRequest { - id - url - number - } - } - } - """ - - variables = { - "repositoryId": target_repo_id, - "title": title, - "body": body, - "headRefName": head_ref_full, - "baseRefName": base_ref - } - - if self.dry_run: - self.log(f"🧪 DRY RUN: Would create cross-repo PR '{title}' from {head_ref_full} to {base_ref}") - return "dry-run-pr-id", f"https://github.com/{target_owner}/{target_repo}/pull/0", 0 - - try: - data = self.run(mutation, variables) - pr_data = data["data"]["createPullRequest"]["pullRequest"] - - pr_id = pr_data["id"] - pr_url = pr_data["url"] - pr_number = pr_data["number"] - - self.log(f"āœ… Cross-repo pull request created: {pr_url}") - return pr_id, pr_url, pr_number - - except Exception as e: - self.log(f"āŒ Failed to create cross-repo pull request: {str(e)}") - raise - - def create_pull_request(self, repository_id: str, title: str, body: str, head_ref: str, base_ref: str = "main") -> tuple[str, str, int]: - """Create a pull request""" - self.log(f"Creating pull request with createPullRequest mutation from {head_ref} to {base_ref}...") - mutation = """ - mutation($repositoryId:ID!, $title:String!, $body:String!, $headRefName:String!, $baseRefName:String!) { - createPullRequest(input:{ - repositoryId:$repositoryId, - title:$title, - body:$body, - headRefName:$headRefName, - baseRefName:$baseRefName - }) { - pullRequest { - id - url - number - title - } - } - } - """ - variables = { - "repositoryId": repository_id, - "title": title, - "body": body, - "headRefName": head_ref, - "baseRefName": base_ref - } - data = self.run(mutation, variables) - if data.get("dryRun"): - return ("DRY_RUN_PR_ID", "https://github.com/owner/repo/pull/456", 456) - pr = data["data"]["createPullRequest"]["pullRequest"] - self.log(f"Pull request created: {pr['url']} (#{pr['number']})") - return (pr["id"], pr["url"], pr["number"]) - - def assign_to_copilot(self, assignable_id: str, actor_ids: list[str]) -> bool: - """Assign issue to Copilot - - Returns True if successful, False otherwise. - """ - self.log("Assigning with replaceActorsForAssignable mutation...") - mutation = """ - mutation($assignableId:ID!, $actorIds:[ID!]!) { - replaceActorsForAssignable(input:{assignableId:$assignableId, actorIds:$actorIds}) { - assignable { - ... on Issue { - id - title - assignees(first:10) { nodes { login } } - url - } - ... on PullRequest { - id - title - assignees(first:10) { nodes { login } } - url - } - } - } - } - """ - try: - data = self.run(mutation, {"assignableId": assignable_id, "actorIds": actor_ids}) - - if data.get("dryRun"): - self.log("[DRY-RUN] Would have assigned Copilot.") - return True - - assigned = data["data"]["replaceActorsForAssignable"]["assignable"]["assignees"]["nodes"] - assignees = ", ".join([n["login"] for n in assigned]) or "(none)" - self.log(f"Current assignees: {assignees}") - return True - except Exception as e: - error_message = str(e) - self.log(f"Error assigning Copilot: {error_message}") - - # Provide specific guidance for common permission issues - if "FORBIDDEN" in error_message and "ReplaceActorsForAssignable" in error_message: - self.log("") - self.log("šŸ“‹ Permission Issue: Cannot assign GitHub Copilot") - self.log(" This is a repository permission limitation, not an application error.") - self.log("") - self.log(" Possible solutions:") - self.log(" 1. Repository admin can assign Copilot manually to the PR") - self.log(" 2. Repository admin can grant assignment permissions") - self.log(" 3. The @copilot comment will still notify Copilot to work on the PR") - self.log("") - self.log(" āœ… The PR was created successfully with @copilot instructions") - self.log(" āœ… Copilot can still see and act on the @copilot comment") - elif "NOT_FOUND" in error_message: - self.log("") - self.log("šŸ“‹ Copilot Actor Not Found") - self.log(" This repository may not have GitHub Copilot enabled or available.") - self.log(" The @copilot comment was still added to notify available Copilot services.") - - return False - - def add_copilot_comment(self, owner: str, repo: str, pr_number: int, - file_path: str, old_text: str, new_text: str, branch_name: str, - work_item_id: str = None, item_source: str = None, doc_url: str = None, - custom_instructions: str = None) -> bool: - """Add a comment mentioning @copilot with explicit instructions to work on THIS PR - - This tells Copilot to make changes in the current PR's branch, not create a new PR. - - Args: - owner: Repository owner - repo: Repository name - pr_number: Pull request number - file_path: Path to the file to modify - old_text: Text to find and replace - new_text: New text to replace with - branch_name: Branch name for this PR - work_item_id: Reference ID for tracking (optional) - item_source: Source of the item (optional) - - Returns True if successful, False otherwise. - """ - if self.dry_run: - self.log(f"[DRY-RUN] Would add @copilot comment to PR #{pr_number}") - return True - - try: - rest_headers = { - "Authorization": f"Bearer {self.token}", - "Accept": "application/vnd.github+json", - "User-Agent": USER_AGENT - } - - # Build reference ID if provided - if work_item_id: - reference_id = f"**Reference ID:** {work_item_id}\n" - else: - reference_id = "" - - # Build document reference - if file_path and not file_path.startswith("File path not specified"): - doc_ref = f"**Document to modify:** `{file_path}`\n" - file_instruction = f"2. Locate the file: `{file_path}`" - elif doc_url: - doc_ref = f"**Document URL:** {doc_url}\n" - file_instruction = f"2. Locate the file from this document URL: {doc_url}" - else: - doc_ref = "**Note:** File path not specified\n" - file_instruction = "2. Review the PR description to identify the file(s) that need to be modified" - - # Build custom instructions section - if custom_instructions and custom_instructions.strip(): - custom_instructions_section = f""" -**Custom AI Instructions:** -{custom_instructions.strip()} - -""" - else: - custom_instructions_section = "" - - # Create a comment mentioning @copilot with VERY explicit instructions - comment_body = f"""@copilot - -{reference_id}{doc_ref} - -**Instructions:** - -Task: Update the file with the changes requested above. - -Steps to complete: - -Locate the file containing the reference shown below. -Find the reference text within the file -Replace it with the 'Proposed New Text' shown above or use the reference as guidance -Maintain the existing formatting, indentation, and structure -Ensure no other content in the file is modified - -> [!IMPORTANT] -> Only replace the specified text - do not make additional changes. -> Preserve all formatting, links, and code blocks. -> If the current text cannot be found exactly, search for similar text. -> Do not remove any text unless the reference or suggested guidance indicates to do so, if the text is obsolete or incorrect. - -1. Make changes to `{branch_name}` branch for this pull request. - -{file_instruction} - -3. Find this reference in the content: -``` -{old_text} -``` - -4. Use this text as guidance for the new content: -``` -{new_text} -``` - -5. Ensure the changes align with the context provided. - -6. Do a freshness check to ensure the file content is up-to-date before making changes. - -7. Commit the changes to the `{branch_name}` branch - -> [!NOTE] -> If guidance is empty, follow the reference to make changes. - -{custom_instructions_section} -Thank you! -""" - - # Post the comment to the PR - comments_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" - comment_data = {"body": comment_body} - - resp = requests.post(comments_url, headers=rest_headers, json=comment_data, timeout=30) - - if resp.status_code == 403: - self.log("āŒ Permission denied when adding comment") - return False - - resp.raise_for_status() - self.log(f"āœ… Added @copilot comment to PR #{pr_number}") - self.log(" Copilot has been instructed to work on THIS PR's branch") - return True - - except requests.HTTPError as e: - self.log(f"āŒ HTTP Error adding comment: {e}") - return False - except Exception as e: - self.log(f"āŒ Error adding comment: {str(e)}") - return False - - def add_pr_suggestion(self, owner: str, repo: str, pr_number: int, file_path: str, - old_text: str, new_text: str) -> bool: - """Add a suggested change comment to a PR - - This creates a review comment with a code suggestion that can be applied - with one click, keeping everything in the same PR. - - Returns True if successful, False otherwise. - """ - if self.dry_run: - self.log(f"[DRY-RUN] Would add suggested change to PR #{pr_number}") - return True - - try: - # Use REST API to create a review comment with suggestion - rest_headers = { - "Authorization": f"Bearer {self.token}", - "Accept": "application/vnd.github+json", - "User-Agent": USER_AGENT - } - - # First, get the latest commit SHA from the PR - pr_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" - resp = requests.get(pr_url, headers=rest_headers, timeout=30) - resp.raise_for_status() - pr_data = resp.json() - commit_sha = pr_data["head"]["sha"] - - self.log(f"Latest commit SHA: {commit_sha}") - - # Get the file content to find line numbers - file_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={commit_sha}" - resp = requests.get(file_url, headers=rest_headers, timeout=30) - - if resp.status_code == 404: - self.log(f"āš ļø File not found in PR: {file_path}") - return False - - resp.raise_for_status() - file_data = resp.json() - - content = base64.b64decode(file_data["content"]).decode('utf-8') - lines = content.split('\n') - - # Find the line number where the old text appears - old_text_lines = old_text.split('\n') - start_line = None - - for i in range(len(lines) - len(old_text_lines) + 1): - if '\n'.join(lines[i:i+len(old_text_lines)]) == old_text: - start_line = i + 1 # Line numbers are 1-based - break - - if not start_line: - self.log("āš ļø Could not find text in file to create suggestion") - return False - - end_line = start_line + len(old_text_lines) - 1 - - # Create a review comment with suggested change - suggestion_body = f"""```suggestion -{new_text} -``` - -**Automated Suggestion:** This change was requested. - -Click "Commit suggestion" above to apply this change directly to the PR.""" - - comment_data = { - "body": suggestion_body, - "commit_id": commit_sha, - "path": file_path, - "line": end_line, - "start_line": start_line if start_line != end_line else None, - "start_side": "RIGHT" - } - - # Remove start_line if it's the same as line (single-line comment) - if start_line == end_line: - del comment_data["start_line"] - - comments_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments" - resp = requests.post(comments_url, headers=rest_headers, json=comment_data, timeout=30) - - if resp.status_code == 403: - self.log("āŒ Permission denied when adding suggestion") - return False - - resp.raise_for_status() - self.log(f"āœ… Added suggested change comment to PR #{pr_number}") - self.log(" User can click 'Commit suggestion' to apply it") - return True - - except requests.HTTPError as e: - self.log(f"āŒ HTTP Error adding suggestion: {e}") - if hasattr(e, 'response') and e.response is not None: - self.log(f" Response: {e.response.text[:200]}") - return False - except Exception as e: - self.log(f"āŒ Error adding suggestion: {str(e)}") - return False - - def create_branch_with_placeholder(self, owner: str, repo: str, branch_name: str, instructions: str) -> bool: - """Create a branch with a placeholder commit using REST API - - This creates a branch from main and adds a .copilot-instructions.md file - so that the branch has at least one commit, allowing PR creation. - - Returns True if successful, False otherwise. - """ - if self.dry_run: - self.log(f"[DRY-RUN] Would create branch {branch_name} with placeholder commit") - return True - - try: - # Use REST API for branch/file creation - rest_headers = { - "Authorization": f"Bearer {self.token}", - "Accept": "application/vnd.github+json", - "User-Agent": USER_AGENT - } - - # 1. Get the SHA of the main branch - self.log(f"Getting SHA of main branch...") - ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/main" - resp = requests.get(ref_url, headers=rest_headers, timeout=30) - resp.raise_for_status() - main_sha = resp.json()["object"]["sha"] - self.log(f"Main branch SHA: {main_sha}") - - # 2. Create new branch from main - self.log(f"Creating branch {branch_name}...") - create_ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/refs" - create_ref_payload = { - "ref": f"refs/heads/{branch_name}", - "sha": main_sha - } - resp = requests.post(create_ref_url, headers=rest_headers, json=create_ref_payload, timeout=30) - - # Check for permission errors - if resp.status_code == 403: - self.log("āŒ Permission denied: GitHub token doesn't have write access to this repository") - self.log(f" Repository: {owner}/{repo}") - self.log(" Required permission: 'repo' scope with write access") - self.log("") - self.log(" Please verify:") - self.log(" 1. Your token has the 'repo' scope enabled") - self.log(" 2. You have write/push access to this repository") - self.log(" 3. The repository exists and the name is correct") - self.log("") - self.log(" TIP: You can still create Issues (uncheck the PR checkbox)") - return False - - # Branch might already exist, that's okay - if resp.status_code == 422: - error_detail = resp.json() - if "already exists" in str(error_detail).lower(): - self.log(f"Branch {branch_name} already exists, using existing branch") - return True - else: - self.log(f"Error creating branch: {error_detail}") - - resp.raise_for_status() - self.log(f"āœ… Branch {branch_name} created") - - # 3. Create a placeholder file with instructions - self.log("Creating placeholder commit with Copilot instructions...") - file_content = f"""# Copilot Instructions - -This is a placeholder file created to allow PR creation. - -## Task -{instructions} - -Please process the instructions above and make the necessary changes to the documentation. - -Once you've made the changes, you can delete this file. -""" - - encoded_content = base64.b64encode(file_content.encode('utf-8')).decode() - - file_payload = { - "message": f"Add Copilot instructions for {branch_name}", - "content": encoded_content, - "branch": branch_name - } - - file_url = f"https://api.github.com/repos/{owner}/{repo}/contents/.copilot-instructions.md" - resp = requests.put(file_url, headers=rest_headers, json=file_payload, timeout=30) - resp.raise_for_status() - - self.log(f"āœ… Placeholder commit created in branch {branch_name}") - return True - - except requests.HTTPError as e: - self.log(f"āŒ HTTP Error creating branch with placeholder: {e}") - if e.response.status_code == 403: - self.log(" Permission denied - token doesn't have write access") - return False - except Exception as e: - self.log(f"āŒ Error creating branch with placeholder: {str(e)}") - return False - - -# Backward compatibility alias -GitHubAPI = GitHubGQL \ No newline at end of file diff --git a/src/app_components/main_gui.py b/src/app_components/main_gui.py deleted file mode 100644 index 7739f8a..0000000 --- a/src/app_components/main_gui.py +++ /dev/null @@ -1,3123 +0,0 @@ -""" -Main GUI Interface (Flet version) -The primary user interface for the application -""" - -import flet as ft -# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors) -ft.icons = ft.Icons -ft.colors = ft.Colors -import os -import threading -import webbrowser -import asyncio -from typing import List, Dict, Any, Optional -from pathlib import Path - -from .utils import Logger -from .settings_dialog import SettingsDialog -from .processing_log_dialog import ProcessingLogDialog - - -class DryRunVar: - """Compatibility class for dry run variable""" - - def __init__(self, app): - self.app = app - - def get(self): - return self.app.dry_run_enabled - - def set(self, value): - self.app.dry_run_enabled = bool(value) - - -class MainGUI: - """Main GUI interface for the application""" - - def __init__(self, page: ft.Page, config_manager, ai_manager, app): - self.page = page - self.config_manager = config_manager - self.ai_manager = ai_manager - self.app = app - - # Application state - self.current_work_items = [] - self.current_item_index = 0 - self.current_organization = None - self.edit_mode = False - self.workflow_items = {} - self.current_workflow_items = [] - self.active_workflow_item = None # Currently selected item from All Items list - - # Repository data - self.target_repos = [] - self.forked_repos = {'local': [], 'github': []} - - # Create dry run compatibility wrapper - self.dry_run_var = DryRunVar(app) - - # UI References - self.status_text_ref = ft.Ref[ft.Text]() - self.progress_bar_ref = ft.Ref[ft.ProgressBar]() - self.work_item_id_ref = ft.Ref[ft.Text]() - self.nature_text_ref = ft.Ref[ft.TextField]() - self.live_doc_url_ref = ft.Ref[ft.TextField]() - self.text_to_change_ref = ft.Ref[ft.TextField]() - self.proposed_new_text_ref = ft.Ref[ft.TextField]() - self.custom_instructions_ref = ft.Ref[ft.TextField]() - self.diff_text_ref = ft.Ref[ft.TextField]() - self.log_text_ref = ft.Ref[ft.TextField]() - self.edit_button_ref = ft.Ref[ft.IconButton]() - self.go_button_ref = ft.Ref[ft.ElevatedButton]() - - # Mode and filter refs - self.tools_mode_ref = ft.Ref[ft.RadioGroup]() - self.repo_source_ref = ft.Ref[ft.RadioGroup]() - self.item_type_ref = ft.Ref[ft.RadioGroup]() - self.create_type_ref = ft.Ref[ft.RadioGroup]() - self.target_repo_dropdown_ref = ft.Ref[ft.Dropdown]() - self.forked_repo_dropdown_ref = ft.Ref[ft.Dropdown]() - self.workflow_item_dropdown_ref = ft.Ref[ft.Dropdown]() - self.active_item_display_ref = ft.Ref[ft.Container]() - self.item_counter_ref = ft.Ref[ft.Text]() - - # DataTable ref for all items - self.items_table_ref = ft.Ref[ft.DataTable]() - - # All items display - self.all_items_container_ref = ft.Ref[ft.Column]() - self.all_items_search_ref = ft.Ref[ft.TextField]() - self.all_items_type_filter_ref = ft.Ref[ft.RadioGroup]() - self.all_items_repo_filter_ref = ft.Ref[ft.RadioGroup]() - self.item_detail_dialog_ref = ft.Ref[ft.AlertDialog]() - - # Sidebar state - self.sidebar_visible = True - self.sidebar_ref = ft.Ref[ft.Container]() - self.tools_content_ref = ft.Ref[ft.Column]() - - # Initialize cache manager - from .cache_manager import CacheManager - self.cache_manager = CacheManager(cache_duration_hours=24) - - # Initialize logger - 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 - self.config_manager.register_listener(self._on_settings_changed) - - def build(self) -> ft.Container: - """Build and return the main UI with VS Code-style layout""" - # Top navigation bar with branding and buttons - top_nav = ft.Container( - content=ft.Row( - [ - ft.IconButton( - icon=ft.icons.MENU, - tooltip="Toggle GitHub Tools", - on_click=self._toggle_sidebar, - ), - ft.Icon(ft.icons.BOLT, color="blue", size=24), - ft.Text( - "GitHub Pulse", - size=20, - weight=ft.FontWeight.BOLD, - color="blue", - ), - ft.Container(expand=True), - ft.IconButton( - icon=ft.icons.LIST_ALT, - tooltip="Processing Log", - on_click=self._open_processing_log, - ), - ft.IconButton( - icon=ft.icons.SETTINGS, - tooltip="Settings", - on_click=self._open_settings, - ), - ], - alignment=ft.MainAxisAlignment.START, - ), - padding=15, - bgcolor=ft.colors.BLUE_GREY_900, - ) - - # Create sidebar (GitHub Tools) - collapsible - sidebar = ft.Container( - ref=self.sidebar_ref, - content=self._create_sidebar_content(), - width=350, - bgcolor=ft.colors.BLUE_GREY_900, - padding=15, - ) - - # Create main content area (tabs + status) - main_content = ft.Column( - [ - self._create_status_section(), - self._create_tabs_section(), - ], - spacing=10, - expand=True, - ) - - # Bottom section: Sidebar on left, content on right - bottom_section = ft.Row( - [ - sidebar, - ft.VerticalDivider(width=1), - ft.Container( - content=main_content, - expand=True, - padding=20, - ), - ], - spacing=0, - expand=True, - vertical_alignment=ft.CrossAxisAlignment.STRETCH, - ) - - # Create hidden log text field for the processing log dialog - hidden_log_text = ft.TextField( - ref=self.log_text_ref, - multiline=True, - read_only=True, - text_style=ft.TextStyle(font_family="Courier New"), - visible=False, # Hidden from main UI - ) - - # Overall layout: Top nav + bottom section + hidden log field - app_layout = ft.Column( - [ - top_nav, - ft.Divider(height=1), - bottom_section, - hidden_log_text, # Hidden but accessible for dialog - ], - spacing=0, - expand=True, - ) - - # Initialize logger after UI is created - if self.log_text_ref.current: - self.logger = Logger(self.log_text_ref.current) - - # Start async initialization - self.page.run_task(self._async_init) - - return ft.Container( - content=app_layout, - expand=True, - ) - - async def _async_init(self): - """Async initialization""" - await asyncio.sleep(0.5) - await self._load_custom_instructions() - await self._init_load_repos() - # Auto-load cached items after repos are loaded - await self._auto_load_cached_items() - - def _toggle_sidebar(self, e): - """Toggle sidebar visibility""" - self.sidebar_visible = not self.sidebar_visible - if self.sidebar_ref.current: - if self.sidebar_visible: - self.sidebar_ref.current.width = 350 - self.sidebar_ref.current.visible = True - else: - self.sidebar_ref.current.width = 0 - self.sidebar_ref.current.visible = False - self.page.update() - - def _create_title_section(self) -> ft.Container: - """Create the title section with buttons""" - return ft.Container( - content=ft.Row( - [ - ft.Container(expand=True), - ft.IconButton( - icon=ft.icons.SETTINGS, - tooltip="Settings", - on_click=self._open_settings, - ), - ], - alignment=ft.MainAxisAlignment.END, - ), - padding=ft.padding.only(bottom=10), - ) - - def _create_sidebar_content(self) -> ft.Column: - """Create the controls section""" - # Mode selection - mode_controls = ft.RadioGroup( - ref=self.tools_mode_ref, - content=ft.Row([ - ft.Radio(value="create", label="Create PR/Issue"), - ft.Radio(value="action", label="Action Existing PR/Issue"), - ]), - value="action", - on_change=self._on_mode_changed, - ) - - # Target Repository - target_repo_row = ft.Row( - [ - ft.Dropdown( - ref=self.target_repo_dropdown_ref, - label="Target Repository", - hint_text="Select target repository", - options=[], - expand=True, - on_change=self._on_repo_selection_changed, - ), - ft.IconButton( - icon=ft.icons.REFRESH, - tooltip="Refresh", - on_click=lambda e: self.page.run_task(self._refresh_target_repos_async), - ), - ft.IconButton( - icon=ft.icons.SEARCH, - tooltip="Search", - on_click=lambda e: self.page.run_task(self._search_target_repos_async), - ), - ], - spacing=5, - ) - - # Forked Repository - forked_repo_row = ft.Row( - [ - ft.Dropdown( - ref=self.forked_repo_dropdown_ref, - label="Forked Repository", - hint_text="Select forked repository", - options=[], - expand=True, - on_change=self._on_repo_selection_changed, - ), - ft.IconButton( - icon=ft.icons.REFRESH, - tooltip="Refresh", - on_click=lambda e: self.page.run_task(self._refresh_forked_repos_async), - ), - ft.IconButton( - icon=ft.icons.DOWNLOAD, - tooltip="Clone", - on_click=self._clone_forked_repo, - ), - ], - spacing=5, - ) - - # Action controls (for action mode) - action_controls = ft.Column( - [ - ft.Text("Active Item", weight=ft.FontWeight.BOLD, size=14), - ft.Row([ - ft.Container( - ref=self.active_item_display_ref, - content=ft.Text( - "No item selected", - color=ft.colors.GREY_500, - italic=True, - text_align=ft.TextAlign.CENTER, - ), - padding=10, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - bgcolor=ft.colors.GREY_900, - expand=True, - ), - ], spacing=5), - ft.Divider(height=10), - ft.Text("All Items", weight=ft.FontWeight.BOLD, size=14), - ft.Row([ - ft.ElevatedButton( - "šŸ“„ Pull PRs/Issues", - on_click=lambda e: self.page.run_task(self._load_workflow_items_async), - ), - ft.Text(ref=self.item_counter_ref, value="No items loaded"), - ]), - ft.TextField( - ref=self.all_items_search_ref, - hint_text="Search items...", - prefix_icon=ft.icons.SEARCH, - dense=True, - on_change=self._on_all_items_search_changed, - border_radius=8, - ), - ft.Text("Source Repo", weight=ft.FontWeight.BOLD), - ft.RadioGroup( - ref=self.all_items_type_filter_ref, - content=ft.Row([ - ft.Radio(value="both", label="Both"), - ft.Radio(value="prs", label="PRs"), - ft.Radio(value="issues", label="Issues"), - ], spacing=5), - value="both", - on_change=self._on_all_items_filter_changed, - ), - ft.Text("Item Type", weight=ft.FontWeight.BOLD), - ft.RadioGroup( - ref=self.all_items_repo_filter_ref, - content=ft.Row([ - ft.Radio(value="both", label="Both"), - ft.Radio(value="target", label="Target"), - ft.Radio(value="fork", label="Fork"), - ], spacing=5), - value="both", - on_change=self._on_all_items_filter_changed, - ), - ft.Container( - content=ft.Column( - ref=self.all_items_container_ref, - controls=[ - ft.Text("No items loaded", color=ft.colors.GREY_500, italic=True, text_align=ft.TextAlign.CENTER) - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - ), - height=300, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - padding=5, - ), - ], - spacing=10, - ) - - # Create controls (for create mode) - create_controls = ft.Column( - [ - ft.Text("Create Type", weight=ft.FontWeight.BOLD), - ft.RadioGroup( - ref=self.create_type_ref, - content=ft.Row([ - ft.Radio(value="pull_request", label="Pull Request"), - ft.Radio(value="issue", label="Issue"), - ]), - value="pull_request", - ), - ft.ElevatedButton( - "āœļø Create New", - on_click=self._create_new_item, - ), - ], - spacing=10, - visible=False, - ) - - # GitHub Tools content - return ft.Column( - [ - ft.Row([ - ft.Icon(ft.icons.SOURCE, size=20), - ft.Text("GitHub Tools", size=18, weight=ft.FontWeight.BOLD), - ]), - ft.Divider(height=20), - mode_controls, - ft.Divider(height=10), - target_repo_row, - forked_repo_row, - ft.Divider(height=10), - action_controls, - create_controls, - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - expand=True, # Make column expand to fill available space - ) - - def _create_status_section(self) -> ft.Container: - """Create the status section""" - return ft.Container( - content=ft.Column([ - ft.ProgressBar(ref=self.progress_bar_ref, visible=False), - ft.Text(ref=self.status_text_ref, value="Ready", size=14), - ]), - padding=ft.padding.symmetric(vertical=10), - ) - - def _create_tabs_section(self) -> ft.Container: - """Create the tabbed interface""" - tabs = ft.Tabs( - selected_index=0, - animation_duration=300, - tabs=[ - ft.Tab( - text="Current Item", - icon=ft.icons.DESCRIPTION, - content=self._create_current_item_tab() - ), - ft.Tab( - text="View Diff", - icon=ft.icons.DIFFERENCE, - content=self._create_diff_tab() - ), - ft.Tab( - text="AI Action Plan", - icon=ft.icons.AUTO_AWESOME, - content=self._create_ai_plan_tab() - ), - ], - expand=True, - ) - - return ft.Container( - content=tabs, - expand=True, - ) - - def _create_current_item_tab(self) -> ft.Container: - """Create the current item tab""" - # Create a container to hold the dynamic content - self.current_item_content_ref = ft.Ref[ft.Column]() - - # Default empty state - default_content = ft.Column( - [ - ft.Container( - content=ft.Column([ - ft.Icon(ft.icons.INBOX, size=64, color=ft.colors.GREY_500), - ft.Text( - "No item selected", - size=18, - weight=ft.FontWeight.BOLD, - color=ft.colors.GREY_500, - ), - ft.Text( - "Select a PR or Issue from the sidebar to view details", - 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, - ), - ], - ref=self.current_item_content_ref, - spacing=15, - scroll=ft.ScrollMode.AUTO, - ) - - return ft.Container( - content=ft.ListView( - controls=[default_content], - spacing=0, - padding=20, - ), - expand=True, - ) - - def _create_diff_tab(self) -> ft.Container: - """Create the diff view tab""" - diff_buttons = ft.Row( - [ - ft.ElevatedButton( - "Find .diff Files", - icon=ft.icons.SEARCH, - on_click=self.find_and_load_diff_files, - ), - ft.ElevatedButton( - "Clear Diff", - icon=ft.icons.CLEAR, - on_click=self.clear_diff_display, - ), - ], - spacing=10, - ) - - diff_text = ft.TextField( - ref=self.diff_text_ref, - multiline=True, - read_only=True, - expand=True, - text_style=ft.TextStyle(font_family="Courier New"), - ) - - return ft.Container( - content=ft.Column([ - diff_buttons, - diff_text, - ], spacing=10, expand=True), - padding=20, - expand=True, - ) - - def _create_all_items_tab(self) -> ft.Container: - """Create the all items tab""" - # DataTable for items - items_table = ft.DataTable( - ref=self.items_table_ref, - columns=[ - ft.DataColumn(ft.Text("Repo")), - ft.DataColumn(ft.Text("Type")), - ft.DataColumn(ft.Text("ID")), - ft.DataColumn(ft.Text("Title")), - ft.DataColumn(ft.Text("Author")), - ft.DataColumn(ft.Text("Status")), - ], - rows=[], - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - heading_row_color=ft.colors.BLUE_GREY_100, - ) - - set_current_button = ft.ElevatedButton( - "Set as Current Item", - icon=ft.icons.CHECK_CIRCLE, - on_click=self._select_current_item, - ) - - return ft.Container( - content=ft.Column([ - set_current_button, - ft.ListView( - controls=[items_table], - expand=True, - ), - ], spacing=10, expand=True), - padding=20, - expand=True, - ) - - # ===== Event Handlers ===== - - def _on_settings_changed(self, key: str, value: any): - """ - Handle settings changes from settings dialog (live updates). - - Args: - key: Setting key that changed - value: New value - """ - # Update repository dropdowns when repos change in settings - if key == 'GITHUB_REPO': - if self.target_repo_dropdown_ref.current: - self.target_repo_dropdown_ref.current.value = value - self.page.update() - print(f"āœ“ Main GUI: Target repo updated to {value}") - - elif key == 'FORKED_REPO': - if self.forked_repo_dropdown_ref.current: - self.forked_repo_dropdown_ref.current.value = value - self.page.update() - print(f"āœ“ Main GUI: Forked repo updated to {value}") - - def _on_mode_changed(self, e): - """Handle mode change between create and action""" - # This would toggle visibility of create vs action controls - # Implementation depends on UI structure - pass - - def _on_repo_selection_changed(self, e): - """Handle repository selection change""" - # Save selected repos to settings - config = self.config_manager.get_config() - - if self.target_repo_dropdown_ref.current and self.target_repo_dropdown_ref.current.value: - target_value = self.target_repo_dropdown_ref.current.value - # Don't save separator headers - if not target_value.startswith('---'): - config['GITHUB_REPO'] = target_value - - if self.forked_repo_dropdown_ref.current and self.forked_repo_dropdown_ref.current.value: - forked_value = self.forked_repo_dropdown_ref.current.value - # Don't save separator headers - if not forked_value.startswith('---'): - config['FORKED_REPO'] = forked_value - - # Save to config - self.config_manager.save_configuration(config) - - # Clear workflow items when repos change - self.workflow_items = {} - self.current_workflow_items = [] - if self.workflow_item_dropdown_ref.current: - self.workflow_item_dropdown_ref.current.options = [] - self.page.update() - - # Auto-load cached items for the newly selected repos - self.page.run_task(self._auto_load_cached_items_on_repo_change) - - def _on_workflow_item_selected(self, e): - """Handle workflow item selection""" - if not self.workflow_item_dropdown_ref.current: - return - - selected = self.workflow_item_dropdown_ref.current.value - if selected: - # Find the item and display it - for item in self.current_workflow_items: - if hasattr(item, 'title') and item.title == selected: - self._display_workflow_item(item) - break - - def _on_all_items_search_changed(self, e): - """Handle search field change in All Items list""" - if not self.all_items_search_ref.current: - return - - search_query = self.all_items_search_ref.current.value or "" - type_filter = self.all_items_type_filter_ref.current.value if self.all_items_type_filter_ref.current else "both" - repo_filter = self.all_items_repo_filter_ref.current.value if self.all_items_repo_filter_ref.current else "both" - self._populate_all_items(search_query, type_filter, repo_filter) - - def _on_all_items_filter_changed(self, e): - """Handle filter change in All Items list (type or repo source)""" - search_query = self.all_items_search_ref.current.value if self.all_items_search_ref.current else "" - type_filter = self.all_items_type_filter_ref.current.value if self.all_items_type_filter_ref.current else "both" - repo_filter = self.all_items_repo_filter_ref.current.value if self.all_items_repo_filter_ref.current else "both" - self._populate_all_items(search_query, type_filter, repo_filter) - - def _filter_workflow_items(self): - """Collect all workflow items (no filtering since toggles were removed)""" - print("=" * 60) - print("COLLECTING WORKFLOW ITEMS") - print("=" * 60) - - # Collect all items from all categories since filter toggles are removed - all_items = [] - for key, items in self.workflow_items.items(): - all_items.extend(items) - - self.current_workflow_items = all_items - print(f"DEBUG: Collected {len(all_items)} total items") - print(f"DEBUG: Available keys in workflow_items: {list(self.workflow_items.keys())}") - - if self.logger: - self.logger.log(f"Collected {len(all_items)} workflow items from all categories") - self.logger.log(f"Available workflow item keys: {list(self.workflow_items.keys())}") - - # Update item counter if it exists - if self.item_counter_ref.current: - count_text = f"{len(all_items)} item(s) loaded" - self.item_counter_ref.current.value = count_text - print(f"DEBUG: Counter text set to: {count_text}") - - print("DEBUG: Calling page.update()...") - self.page.update() - print("DEBUG: page.update() completed") - - def _display_workflow_item(self, item): - """Display a workflow item in the Current Item tab""" - if not self.current_item_content_ref.current: - return - - # Get repo string based on source - config = self.config_manager.get_config() - if item.repo_source == "target": - repo_str = config.get('GITHUB_REPO', '') - else: - repo_str = config.get('FORKED_REPO', '') - - # Fetch comments - comments = [] - pr_files = [] - try: - workflow_manager = self._get_workflow_manager() - comments = workflow_manager.fetch_comments(repo_str, item.number, item.item_type == "pull_request") - - # Fetch PR files if this is a pull request - if item.item_type == "pull_request": - pr_files = workflow_manager.fetch_pr_files(repo_str, item.number) - except Exception as e: - print(f"Error fetching item details: {e}") - if self.logger: - self.logger.log(f"Error fetching item details: {e}") - - # Build the display - controls = [] - - # Header section - header = ft.Container( - content=ft.Column([ - ft.Row([ - ft.Container( - content=ft.Text( - "PR" if item.item_type == "pull_request" else "Issue", - size=12, - weight=ft.FontWeight.BOLD, - color=ft.colors.WHITE, - ), - bgcolor=ft.colors.GREEN if item.item_type == "pull_request" else ft.colors.ORANGE, - padding=ft.padding.symmetric(horizontal=8, vertical=4), - border_radius=4, - ), - ft.Text(f"#{item.number}", size=18, weight=ft.FontWeight.BOLD), - ft.Container(expand=True), - ft.IconButton( - icon=ft.icons.OPEN_IN_BROWSER, - tooltip="Open in GitHub", - on_click=lambda e: self.page.launch_url(item.url), - ), - ], alignment=ft.MainAxisAlignment.START), - ft.Text(item.title, size=20, weight=ft.FontWeight.BOLD), - ], spacing=8), - padding=15, - bgcolor=ft.colors.BLUE_GREY_900, - border_radius=8, - ) - controls.append(header) - - # Basic Info section - info_items = [ - ft.Row([ - ft.Icon(ft.icons.PERSON, size=16, color=ft.colors.BLUE_400), - ft.Text("Created by:", weight=ft.FontWeight.BOLD, size=14), - ft.Text(f"@{item.author}", size=14, color=ft.colors.BLUE_300), - ], spacing=5), - ft.Row([ - ft.Icon(ft.icons.CALENDAR_TODAY, size=16, color=ft.colors.BLUE_400), - ft.Text("Created:", weight=ft.FontWeight.BOLD, size=14), - ft.Text(item.created_at[:10] if item.created_at else 'Unknown', size=14), - ], spacing=5), - ft.Row([ - ft.Icon(ft.icons.UPDATE, size=16, color=ft.colors.BLUE_400), - ft.Text("Last Updated:", weight=ft.FontWeight.BOLD, size=14), - ft.Text(item.updated_at[:10] if item.updated_at else 'Unknown', size=14), - ], spacing=5), - ft.Row([ - ft.Icon(ft.icons.CIRCLE, size=16, color=ft.colors.GREEN if item.state == "open" else ft.colors.PURPLE), - ft.Text("Status:", weight=ft.FontWeight.BOLD, size=14), - ft.Text(item.state.capitalize(), size=14, color=ft.colors.GREEN if item.state == "open" else ft.colors.PURPLE), - ], spacing=5), - ] - - # Add assignees with assign-to-self button - if item.assignees: - assignees_text = ", ".join([f"@{a}" for a in item.assignees]) - info_items.append( - ft.Row([ - ft.Icon(ft.icons.ASSIGNMENT_IND, size=16, color=ft.colors.BLUE_400), - ft.Text("Assigned to:", weight=ft.FontWeight.BOLD, size=14), - ft.Text(assignees_text, size=14, color=ft.colors.BLUE_300), - ft.IconButton( - icon=ft.icons.PERSON_ADD, - icon_size=16, - tooltip="Assign to me", - on_click=lambda _: self._assign_to_self(item, repo_str), - ), - ], spacing=5) - ) - else: - info_items.append( - ft.Row([ - ft.Icon(ft.icons.ASSIGNMENT_IND, size=16, color=ft.colors.GREY_600), - ft.Text("Assigned to:", weight=ft.FontWeight.BOLD, size=14), - ft.Text("Unassigned", size=14, color=ft.colors.GREY_500, italic=True), - ft.IconButton( - icon=ft.icons.PERSON_ADD, - icon_size=16, - tooltip="Assign to me", - on_click=lambda _: self._assign_to_self(item, repo_str), - ), - ], spacing=5) - ) - - # PR-specific info - if item.item_type == "pull_request": - merge_status_color = ft.colors.GREEN if item.merged else (ft.colors.ORANGE if item.state == "open" else ft.colors.GREY_600) - merge_status_text = "Merged" if item.merged else ("Pending Merge" if item.state == "open" else "Closed without merge") - info_items.append( - ft.Row([ - ft.Icon(ft.icons.MERGE_TYPE, size=16, color=merge_status_color), - ft.Text("Merge Status:", weight=ft.FontWeight.BOLD, size=14), - ft.Text(merge_status_text, size=14, color=merge_status_color), - ], spacing=5) - ) - - info_section = ft.Container( - content=ft.Column(info_items, spacing=8), - padding=15, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - ) - controls.append(info_section) - - # Description section (collapsible, collapsed by default) - description_section = ft.ExpansionTile( - title=ft.Text("Description", size=16, weight=ft.FontWeight.BOLD), - subtitle=ft.Text("Click to expand", size=12, color=ft.colors.GREY_500), - initially_expanded=False, - controls=[ - ft.Container( - content=ft.Container( - content=ft.Row([ - ft.Text( - item.body if item.body else "No description provided", - size=14, - selectable=True, - ), - ], spacing=5), - padding=10, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=4, - bgcolor=ft.colors.GREY_900, - ), - margin=ft.margin.only(left=10, right=10, bottom=10), - ), - ], - ) - controls.append( - ft.Container( - content=description_section, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - ) - ) - - # PR Files section - if item.item_type == "pull_request" and pr_files: - files_widgets = [] - for file in pr_files: - status_color = { - 'added': ft.colors.GREEN, - 'removed': ft.colors.RED, - 'modified': ft.colors.ORANGE, - 'renamed': ft.colors.BLUE, - }.get(file['status'], ft.colors.GREY_400) - - files_widgets.append( - ft.Container( - content=ft.Row([ - ft.Icon(ft.icons.INSERT_DRIVE_FILE, size=16, color=status_color), - ft.Text(file['filename'], size=13, expand=True), - ft.Container( - content=ft.Text(file['status'], size=11, color=ft.colors.WHITE), - bgcolor=status_color, - padding=ft.padding.symmetric(horizontal=6, vertical=2), - border_radius=3, - ), - ft.Text(f"+{file['additions']} -{file['deletions']}", - size=12, - color=ft.colors.GREY_400), - ], spacing=8), - padding=8, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=4, - bgcolor=ft.colors.GREY_900, - ) - ) - - files_section = ft.Container( - content=ft.Column([ - ft.Text(f"Modified Files ({len(pr_files)})", size=16, weight=ft.FontWeight.BOLD), - ft.Column( - controls=files_widgets, - spacing=5, - scroll=ft.ScrollMode.AUTO, - height=min(200, len(pr_files) * 50), - ), - ], spacing=8), - padding=15, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - ) - controls.append(files_section) - - # Comments section (collapsible, collapsed by default) - comments_widgets = [] - if comments: - for comment in comments: - comments_widgets.append( - ft.Container( - content=ft.Column([ - ft.Row([ - ft.Icon(ft.icons.PERSON, size=14), - ft.Text(f"@{comment['user']}", weight=ft.FontWeight.BOLD, size=13), - ft.Text( - comment['created_at'][:10] if comment.get('created_at') else '', - size=11, - color=ft.colors.GREY_600 - ), - ], spacing=5), - ft.Text(comment['body'], size=13, selectable=True), - ], spacing=5), - padding=10, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=4, - bgcolor=ft.colors.GREY_900, - ) - ) - else: - comments_widgets.append( - ft.Text("No comments yet", italic=True, color=ft.colors.GREY_500, size=13) - ) - - comments_section = ft.ExpansionTile( - title=ft.Text(f"Comments ({len(comments)})", size=16, weight=ft.FontWeight.BOLD), - subtitle=ft.Text("Click to expand", size=12, color=ft.colors.GREY_500), - initially_expanded=False, - controls=[ - ft.Container( - content=ft.Column( - controls=comments_widgets, - spacing=8, - scroll=ft.ScrollMode.AUTO, - height=min(250, max(100, len(comments) * 80)), - ), - margin=ft.margin.only(left=10, right=10, bottom=10), - ), - ], - ) - controls.append( - ft.Container( - content=comments_section, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - ) - ) - - # AI Analysis section (placeholder for now) - self.ai_analysis_result_ref = ft.Ref[ft.Column]() - ai_section = self._create_ai_analysis_section(item, repo_str, pr_files, comments) - controls.append(ai_section) - - # Update the content - self.current_item_content_ref.current.controls = controls - self.page.update() - - def _create_ai_analysis_section(self, item, repo_str, pr_files, comments): - """Create the AI Analysis section""" - # Check if AI provider is configured - config = self.config_manager.get_config() - ai_provider = config.get('AI_PROVIDER', 'none').lower() - ai_configured = ai_provider and ai_provider != 'none' - - # Create result container - ai_result_container = ft.Column( - ref=self.ai_analysis_result_ref, - controls=[], - spacing=10, - ) - - # Create analyze button or disabled message - if ai_configured: - # Create a wrapper function that captures the parameters - async def run_analysis_wrapper(): - await self._run_ai_analysis_async(item, repo_str, pr_files, comments) - - analyze_button = ft.ElevatedButton( - "Run AI Analysis", - icon=ft.icons.AUTO_AWESOME, - on_click=lambda _: self.page.run_task(run_analysis_wrapper), - ) - button_row = ft.Row([ - analyze_button, - ft.Text( - f"Using {ai_provider.upper()}", - size=12, - color=ft.colors.BLUE_300, - italic=True, - ), - ], spacing=10) - else: - button_row = ft.Container( - content=ft.Row([ - ft.Icon(ft.icons.INFO_OUTLINE, size=16, color=ft.colors.ORANGE), - ft.Text( - "AI Analysis is not available. Please configure an AI provider in Settings.", - size=13, - color=ft.colors.ORANGE, - ), - ], spacing=8), - padding=10, - border=ft.border.all(1, ft.colors.ORANGE), - border_radius=4, - bgcolor=ft.colors.GREY_900, - ) - - ai_section = ft.Container( - content=ft.Column([ - ft.Text("AI Analysis", size=16, weight=ft.FontWeight.BOLD), - ft.Text( - "For PRs: Analyze changes and create a summary. For Issues: Find relevant files and suggest fixes.", - size=12, - color=ft.colors.GREY_400, - ), - button_row, - ai_result_container, - ], spacing=10), - padding=15, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=8, - ) - - return ai_section - - async def _run_ai_analysis_async(self, item, repo_str, pr_files, comments): - """Run AI analysis on the selected item""" - if not self.ai_analysis_result_ref.current: - return - - # Show loading state - self.ai_analysis_result_ref.current.controls = [ - ft.Container( - content=ft.Row([ - ft.ProgressRing(width=16, height=16), - ft.Text("Analyzing...", size=14), - ], spacing=10), - padding=10, - ) - ] - self.page.update() - - def run_analysis(): - try: - config = self.config_manager.get_config() - ai_provider = config.get('AI_PROVIDER', 'none').lower() - - if item.item_type == "pull_request": - # PR Analysis: Summarize changes - result = self._analyze_pr(item, repo_str, pr_files, comments, ai_provider, config) - else: - # Issue Analysis: Find files and suggest fixes - result = self._analyze_issue(item, repo_str, comments, ai_provider, config) - - return result - - except Exception as e: - error_msg = f"Error during AI analysis: {str(e)}" - if self.logger: - self.logger.log(error_msg) - return { - 'success': False, - 'error': error_msg - } - - # Run in thread - result = await asyncio.to_thread(run_analysis) - - # Display results - if result.get('success'): - result_widgets = [ - ft.Container( - content=ft.Column([ - ft.Row([ - ft.Icon(ft.icons.CHECK_CIRCLE, size=16, color=ft.colors.GREEN), - ft.Text("Analysis Complete", weight=ft.FontWeight.BOLD, size=14, color=ft.colors.GREEN), - ], spacing=5), - ft.Divider(height=10), - ft.Text(result.get('summary', ''), size=13, selectable=True), - ], spacing=10), - padding=15, - border=ft.border.all(1, ft.colors.GREEN), - border_radius=8, - bgcolor=ft.colors.GREY_900, - ) - ] - - # Add suggested files for issues - if item.item_type == "issue" and result.get('suggested_files'): - result_widgets.append( - ft.Container( - content=ft.Column([ - ft.Text("Suggested Files to Modify:", weight=ft.FontWeight.BOLD, size=14), - ft.Column([ - ft.Text(f"• {file}", size=13, color=ft.colors.BLUE_300) - for file in result['suggested_files'] - ], spacing=5), - ], spacing=8), - padding=15, - border=ft.border.all(1, ft.colors.BLUE), - border_radius=8, - bgcolor=ft.colors.GREY_900, - ) - ) - - # Add "Create PR with AI Fix" button - result_widgets.append( - ft.ElevatedButton( - "Create PR with AI-Suggested Fix", - icon=ft.icons.AUTO_FIX_HIGH, - on_click=lambda _: self._create_pr_from_ai_fix(item, result), - ) - ) - - self.ai_analysis_result_ref.current.controls = result_widgets - else: - # Show error - self.ai_analysis_result_ref.current.controls = [ - ft.Container( - content=ft.Row([ - ft.Icon(ft.icons.ERROR_OUTLINE, size=16, color=ft.colors.RED), - ft.Text( - result.get('error', 'Unknown error occurred'), - size=13, - color=ft.colors.RED, - ), - ], spacing=8), - padding=10, - border=ft.border.all(1, ft.colors.RED), - border_radius=4, - bgcolor=ft.colors.GREY_900, - ) - ] - - self.page.update() - - def _analyze_pr(self, item, repo_str, pr_files, comments, ai_provider, config): - """Analyze a Pull Request using AI""" - try: - # Build context for AI - context = f"""Pull Request Analysis Request - -Repository: {repo_str} -PR Number: #{item.number} -Title: {item.title} -State: {item.state} -Merged: {item.merged} - -Description: -{item.body if item.body else 'No description provided'} - -Modified Files ({len(pr_files)}): -""" - for file in pr_files: - context += f"\n- {file['filename']} ({file['status']}) [+{file['additions']} -{file['deletions']}]" - - if comments: - context += f"\n\nComments ({len(comments)}):\n" - for comment in comments[:5]: # Limit to first 5 comments - context += f"\n@{comment['user']}: {comment['body'][:200]}...\n" - - context += "\n\nPlease provide a comprehensive summary of this pull request, including:\n" - context += "1. What changes were made\n" - context += "2. The purpose and impact of these changes\n" - context += "3. Any notable patterns or concerns from the comments\n" - context += "4. Overall assessment of the PR" - - # Call AI manager - summary = self.ai_manager.generate_response(context, ai_provider, config) - - if self.logger: - self.logger.log(f"AI PR Analysis completed for PR #{item.number}") - - return { - 'success': True, - 'summary': summary - } - - except Exception as e: - if self.logger: - self.logger.log(f"Error in PR analysis: {e}") - return { - 'success': False, - 'error': str(e) - } - - def _analyze_issue(self, item, repo_str, comments, ai_provider, config): - """Analyze an Issue using AI to suggest fixes""" - try: - # Build context for AI - context = f"""GitHub Issue Analysis Request - -Repository: {repo_str} -Issue Number: #{item.number} -Title: {item.title} -State: {item.state} - -Description: -{item.body if item.body else 'No description provided'} -""" - - if comments: - context += f"\n\nComments ({len(comments)}):\n" - for comment in comments[:5]: # Limit to first 5 comments - context += f"\n@{comment['user']}: {comment['body'][:200]}...\n" - - context += "\n\nPlease analyze this issue and provide:\n" - context += "1. A summary of the issue\n" - context += "2. Suggested files or components that might be causing this issue\n" - context += "3. Recommended approach to fix the issue\n" - context += "4. Any additional considerations\n" - context += "\nFor the suggested files, please list them in a clear format like:\n" - context += "SUGGESTED_FILES: file1.py, file2.js, file3.tsx" - - # Call AI manager - analysis = self.ai_manager.generate_response(context, ai_provider, config) - - # Try to extract suggested files from the response - suggested_files = [] - if "SUGGESTED_FILES:" in analysis: - files_line = analysis.split("SUGGESTED_FILES:")[1].split("\n")[0] - suggested_files = [f.strip() for f in files_line.split(",") if f.strip()] - - if self.logger: - self.logger.log(f"AI Issue Analysis completed for Issue #{item.number}") - - return { - 'success': True, - 'summary': analysis, - 'suggested_files': suggested_files - } - - except Exception as e: - if self.logger: - self.logger.log(f"Error in Issue analysis: {e}") - return { - 'success': False, - 'error': str(e) - } - - def _create_pr_from_ai_fix(self, item, _analysis_result): - """Create a PR with AI-suggested fix for an issue""" - # TODO: Implement PR creation with AI fix - # The analysis_result will contain suggested files and fix recommendations - self._show_snackbar("PR creation with AI fix - Coming soon!", error=False) - if self.logger: - self.logger.log(f"PR creation requested for Issue #{item.number}") - - def _assign_to_self(self, item, repo_str): - """Assign the current PR or Issue to the authenticated user""" - try: - # Get GitHub token - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '') - - if not github_token: - self._show_snackbar("GitHub token not configured", error=True) - return - - # Parse repository - if '/' not in repo_str: - self._show_snackbar("Invalid repository format", error=True) - return - - owner, repo = repo_str.split('/', 1) - - # Get authenticated user - import requests - headers = { - "Authorization": f"Bearer {github_token}", - "Accept": "application/vnd.github+json", - "User-Agent": "github-pulse/1.0" - } - - # First, get the authenticated user's username - user_response = requests.get("https://api.github.com/user", headers=headers, timeout=10) - user_response.raise_for_status() - username = user_response.json().get('login') - - if not username: - self._show_snackbar("Could not get authenticated user", error=True) - return - - # Assign to self using the GitHub API - # For both PRs and Issues, we use the issues endpoint - url = f"https://api.github.com/repos/{owner}/{repo}/issues/{item.number}/assignees" - - # Add the authenticated user to assignees - payload = { - "assignees": [username] - } - - response = requests.post(url, headers=headers, json=payload, timeout=10) - response.raise_for_status() - - # Update the item in memory - if username not in item.assignees: - item.assignees.append(username) - - # Refresh the display - self._display_workflow_item(item) - - self._show_snackbar(f"Successfully assigned to @{username}", error=False) - - if self.logger: - self.logger.log(f"Assigned {item.item_type} #{item.number} to @{username}") - - except requests.exceptions.RequestException as e: - error_msg = f"Error assigning to self: {str(e)}" - self._show_snackbar(error_msg, error=True) - if self.logger: - self.logger.log(error_msg) - except Exception as e: - error_msg = f"Unexpected error: {str(e)}" - self._show_snackbar(error_msg, error=True) - if self.logger: - self.logger.log(error_msg) - - def _populate_all_items(self, search_query: str = "", type_filter: str = "both", repo_filter: str = "both"): - """Populate the all items list with all loaded PRs and Issues - - Args: - search_query: Optional search string to filter items - type_filter: Filter by item type - "both", "prs", or "issues" - repo_filter: Filter by repo source - "both", "target", or "fork" - """ - if not self.all_items_container_ref.current: - return - - # Collect all items from workflow_items - all_items = [] - for key, items in self.workflow_items.items(): - all_items.extend(items) - - # Apply repo source filter - if repo_filter == "target": - all_items = [item for item in all_items if item.repo_source == "target"] - elif repo_filter == "fork": - all_items = [item for item in all_items if item.repo_source == "fork"] - # "both" shows everything, no filtering needed - - # Apply type filter - if type_filter == "prs": - all_items = [item for item in all_items if item.item_type == "pull_request"] - elif type_filter == "issues": - all_items = [item for item in all_items if item.item_type == "issue"] - # "both" shows everything, no filtering needed - - # Apply search filter if provided - if search_query: - search_lower = search_query.lower() - filtered_items = [] - for item in all_items: - # Search in title, number, state, author, and labels - if (search_lower in item.title.lower() or - search_lower in str(item.number) or - search_lower in item.state.lower() or - (item.author and search_lower in item.author.lower()) or - any(search_lower in label.lower() for label in (item.labels or []))): - filtered_items.append(item) - all_items = filtered_items - - if not all_items: - if search_query or type_filter != "both" or repo_filter != "both": - filter_desc = [] - if search_query: - filter_desc.append(f"matching '{search_query}'") - if type_filter == "prs": - filter_desc.append("PRs only") - elif type_filter == "issues": - filter_desc.append("Issues only") - if repo_filter == "target": - filter_desc.append("Target repo only") - elif repo_filter == "fork": - filter_desc.append("Fork repo only") - - msg = "No items " + " and ".join(filter_desc) if filter_desc else "No items loaded" - self.all_items_container_ref.current.controls = [ - ft.Text(msg, color=ft.colors.GREY_500, italic=True) - ] - else: - self.all_items_container_ref.current.controls = [ - ft.Text("No items loaded", color=ft.colors.GREY_500, italic=True) - ] - else: - # Sort by updated_at (most recent first) - all_items.sort(key=lambda x: x.updated_at if hasattr(x, 'updated_at') else '', reverse=True) - - # Create item cards - cards = [] - for item in all_items: - cards.append(self._create_item_card(item)) - - self.all_items_container_ref.current.controls = cards - - self.page.update() - - def _create_item_card(self, item): - """Create a card for a workflow item""" - # Determine repo source label - repo_label = "Target" if item.repo_source == "target" else "Fork" - repo_color = ft.colors.BLUE if item.repo_source == "target" else ft.colors.PURPLE - - # Determine type label - type_label = "PR" if item.item_type == "pull_request" else "Issue" - type_color = ft.colors.GREEN if item.item_type == "pull_request" else ft.colors.ORANGE - - # Create card - return ft.Container( - content=ft.Row( - [ - # Repo source badge - ft.Container( - content=ft.Text(repo_label, size=10, weight=ft.FontWeight.BOLD), - bgcolor=repo_color, - padding=ft.padding.symmetric(horizontal=8, vertical=4), - border_radius=4, - ), - # Type badge - ft.Container( - content=ft.Text(type_label, size=10, weight=ft.FontWeight.BOLD), - bgcolor=type_color, - padding=ft.padding.symmetric(horizontal=8, vertical=4), - border_radius=4, - ), - # Title - ft.Text( - f"#{item.number}: {item.title}", - size=12, - expand=True, - overflow=ft.TextOverflow.ELLIPSIS, - ), - # Select button - ft.IconButton( - icon=ft.icons.CHECK_CIRCLE_OUTLINE, - icon_size=16, - tooltip="Select as current item", - on_click=lambda e, it=item: self._select_item_as_current(it), - ), - # View details button - ft.IconButton( - icon=ft.icons.OPEN_IN_NEW, - icon_size=16, - tooltip="View details", - on_click=lambda e, it=item: self._show_item_detail(it), - ), - ], - spacing=8, - alignment=ft.MainAxisAlignment.START, - ), - padding=8, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=4, - bgcolor=ft.colors.GREY_800, - ) - - def _populate_all_items_table(self): - """Populate the DataTable in the All Items tab with all loaded PRs and Issues""" - if not self.items_table_ref.current: - return - - # Collect all items from workflow_items - all_items = [] - for key, items in self.workflow_items.items(): - all_items.extend(items) - - if not all_items: - self.items_table_ref.current.rows = [] - else: - # Sort by updated_at (most recent first) - all_items.sort(key=lambda x: x.updated_at if hasattr(x, 'updated_at') else '', reverse=True) - - # Create table rows - rows = [] - for item in all_items: - # Determine repo source and type - repo_source = "Target" if item.repo_source == "target" else "Fork" - item_type = "PR" if item.item_type == "pull_request" else "Issue" - - # Get author (item.author is already a string, not a dict) - author = item.author if item.author else 'Unknown' - - # Get state - state = item.state if hasattr(item, 'state') else 'unknown' - - # Get repo name - config = self.config_manager.get_config() - if item.repo_source == "target": - repo_name = config.get('GITHUB_REPO', '') - else: - repo_name = config.get('FORKED_REPO', '') - - # Create row with clickable button - row = ft.DataRow( - cells=[ - ft.DataCell(ft.Text(f"{repo_source}: {repo_name.split('/')[-1] if '/' in repo_name else repo_name}", size=12)), - ft.DataCell(ft.Text(item_type, size=12)), - ft.DataCell(ft.Text(f"#{item.number}", size=12)), - ft.DataCell(ft.Text(item.title[:50] + "..." if len(item.title) > 50 else item.title, size=12)), - ft.DataCell(ft.Text(author, size=12)), - ft.DataCell(ft.Text(state, size=12)), - ], - on_select_changed=lambda e, it=item: self._show_item_detail(it) if e.control.selected else None, - ) - rows.append(row) - - self.items_table_ref.current.rows = rows - - self.page.update() - - def _select_item_as_current(self, item): - """Select an item as the current active workflow item""" - if not self.active_item_display_ref.current: - return - - # Store the active 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 - repo_label = "Target" if item.repo_source == "target" else "Fork" - repo_color = ft.colors.BLUE if item.repo_source == "target" else ft.colors.PURPLE - type_label = "PR" if item.item_type == "pull_request" else "Issue" - type_color = ft.colors.GREEN if item.item_type == "pull_request" else ft.colors.ORANGE - - # Update the active item display with a nice card - self.active_item_display_ref.current.content = ft.Column([ - ft.Row([ - # Repo badge - ft.Container( - content=ft.Text(repo_label, size=10, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE), - bgcolor=repo_color, - padding=ft.padding.symmetric(horizontal=6, vertical=2), - border_radius=4, - ), - # Type badge - ft.Container( - content=ft.Text(type_label, size=10, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE), - bgcolor=type_color, - padding=ft.padding.symmetric(horizontal=6, vertical=2), - border_radius=4, - ), - ft.Container(expand=True), - # Clear button - ft.IconButton( - icon=ft.icons.CLOSE, - icon_size=16, - tooltip="Clear selection", - on_click=self._clear_active_item, - ), - ], spacing=5), - ft.Text( - f"#{item.number}: {item.title}", - size=12, - weight=ft.FontWeight.BOLD, - ), - ], spacing=5) - - # Collect workflow items (filter toggles were removed, so this just collects all items) - self._filter_workflow_items() - - # Display the item - self._display_workflow_item(item) - - # Update the page - self.page.update() - - # Show confirmation - item_type_label = "PR" if item.item_type == "pull_request" else "Issue" - repo_label = "Target" if item.repo_source == "target" else "Fork" - self._show_snackbar(f"Selected {item_type_label} from {repo_label}: {item.title}", error=False) - - def _clear_active_item(self, e=None): - """Clear the active item selection""" - if not self.active_item_display_ref.current: - return - - # Clear the stored active item - self.active_workflow_item = None - - # Reset the display to default "No item selected" - self.active_item_display_ref.current.content = ft.Text( - "No item selected", - color=ft.colors.GREY_500, - italic=True, - text_align=ft.TextAlign.CENTER, - ) - - # Update the page - self.page.update() - - # Show confirmation - self._show_snackbar("Active item cleared", error=False) - - def _show_item_detail(self, item): - """Show detail dialog for a workflow item""" - # Get repo string for fetching comments - config = self.config_manager.get_config() - if item.repo_source == "target": - repo_str = config.get('GITHUB_REPO', '') - else: - repo_str = config.get('FORKED_REPO', '') - - # Build the dialog - dialog = self._build_item_detail_dialog(item, repo_str) - - # Use Flet 0.28+ API: page.open() instead of page.dialog - self.page.open(dialog) - - def _build_item_detail_dialog(self, item, repo_str): - """Build the detail dialog with tabs for Main (Preview) and System (extracted data)""" - - # Get repo name for display - config = self.config_manager.get_config() - if item.repo_source == "target": - repo_name = config.get('GITHUB_REPO', '') - else: - repo_name = config.get('FORKED_REPO', '') - - # Create header with repo and item info - header = ft.Container( - content=ft.Column([ - ft.Row([ - ft.Icon(ft.icons.SOURCE, size=16), - ft.Text(repo_name, size=12, weight=ft.FontWeight.BOLD), - ft.Container( - content=ft.Text( - "PR" if item.item_type == "pull_request" else "Issue", - size=10, - color=ft.colors.WHITE, - ), - bgcolor=ft.colors.GREEN if item.item_type == "pull_request" else ft.colors.ORANGE, - padding=ft.padding.symmetric(horizontal=8, vertical=2), - border_radius=4, - ), - ft.Text(f"#{item.number}", size=12, color=ft.colors.GREY_400), - ], spacing=8), - ft.Text(item.title, size=14, weight=ft.FontWeight.BOLD), - ft.Row([ - ft.Text( - f"by @{item.author if item.author else 'Unknown'}", - size=11, - color=ft.colors.GREY_400, - ), - ft.Text( - f"• {item.state}", - size=11, - color=ft.colors.GREEN if item.state == "open" else ft.colors.PURPLE, - ), - ], spacing=5), - ], spacing=5), - padding=10, - bgcolor=ft.colors.GREY_900, - border_radius=8, - ) - - # Create body preview - body_preview = ft.Container( - content=ft.Column([ - ft.Text("Description", size=12, weight=ft.FontWeight.BOLD), - ft.Container( - content=ft.Text( - item.body if item.body else "No description provided", - size=11, - selectable=True, - ), - padding=10, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=4, - bgcolor=ft.colors.GREY_900, - ), - ], spacing=5), - ) - - # Fetch comments - comments = [] - if repo_str: - try: - workflow_manager = self._get_workflow_manager() - comments = workflow_manager.fetch_comments(repo_str, item.number, item.item_type == "pull_request") - print(f"Fetched {len(comments)} comments for {item.item_type} #{item.number}") - except Exception as e: - print(f"Error fetching comments: {e}") - if self.logger: - self.logger.log(f"Error fetching comments: {e}") - - # Build comments display - comments_widgets = [] - if comments: - for comment in comments: - comments_widgets.append( - ft.Container( - content=ft.Column( - [ - ft.Row([ - ft.Text(f"@{comment['user']}", weight=ft.FontWeight.BOLD, size=12), - ft.Text(comment['created_at'][:10] if comment.get('created_at') else '', size=10, color=ft.colors.GREY_600), - ]), - ft.Text(comment['body'], size=11, selectable=True), - ], - spacing=5, - ), - padding=8, - margin=ft.margin.only(bottom=8), - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=4, - bgcolor=ft.colors.GREY_800, - ) - ) - else: - comments_widgets.append(ft.Text("No comments yet", italic=True, color=ft.colors.GREY_500, size=11)) - - # Comments section - comments_section = ft.Container( - content=ft.Column([ - ft.Text(f"Comments ({len(comments)})", size=12, weight=ft.FontWeight.BOLD), - ft.Column( - controls=comments_widgets, - spacing=5, - scroll=ft.ScrollMode.AUTO, - ), - ], spacing=5), - ) - - # Main content (no tabs, just single scrollable content) - main_content = ft.Container( - content=ft.Column( - [ - header, - body_preview, - comments_section, - ft.Row([ - ft.ElevatedButton( - "Open in GitHub", - icon=ft.icons.OPEN_IN_BROWSER, - on_click=lambda e: self.page.launch_url(item.url), - ), - ft.TextButton( - "Copy URL", - icon=ft.icons.COPY, - on_click=lambda e: self._copy_to_clipboard(item.url), - ), - ], spacing=10), - ], - spacing=15, - scroll=ft.ScrollMode.AUTO, - ), - padding=10, - expand=True, - ) - - # Create close handler that will close this specific dialog - def close_handler(e): - self.page.close(dialog) - - # Create dialog - dialog = ft.AlertDialog( - modal=True, - title=ft.Text(f"{item.item_type.upper()} #{item.number}: {item.title}"), - content=ft.Container( - content=main_content, - width=800, - height=600, - ), - actions=[ - ft.TextButton("Close", on_click=close_handler), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - return dialog - - def _copy_to_clipboard(self, text): - """Copy text to clipboard and show notification""" - self.page.set_clipboard(text) - self._show_snackbar("URL copied to clipboard!", error=False) - - def _get_workflow_manager(self): - """Get or create a WorkflowManager instance""" - github_token = self.config_manager.get_config().get('GITHUB_PAT', '') - if not github_token: - raise ValueError("GitHub token not configured") - - from .workflow import WorkflowManager - return WorkflowManager(github_token, self.logger) - - def _previous_item(self, e): - """Navigate to previous item""" - if self.current_item_index > 0: - self.current_item_index -= 1 - self._display_current_item() - self._update_navigation_buttons() - - def _toggle_edit_mode(self, e): - """Toggle edit mode for proposed new text""" - if not self.proposed_new_text_ref.current or not self.edit_button_ref.current: - return - - self.edit_mode = not self.edit_mode - - if self.edit_mode: - self.proposed_new_text_ref.current.read_only = False - self.edit_button_ref.current.icon = ft.icons.SAVE - self.edit_button_ref.current.tooltip = "Save" - else: - # Save the changes - if self.current_work_items and self.current_item_index < len(self.current_work_items): - self.current_work_items[self.current_item_index]['new_text'] = \ - self.proposed_new_text_ref.current.value - - self.proposed_new_text_ref.current.read_only = True - self.edit_button_ref.current.icon = ft.icons.EDIT - self.edit_button_ref.current.tooltip = "Edit" - - self.page.update() - - def save_custom_instructions(self, e): - """Save custom AI instructions""" - if not self.custom_instructions_ref.current: - return - - instructions = self.custom_instructions_ref.current.value - config_values = {'CUSTOM_INSTRUCTIONS': instructions} - success = self.config_manager.save_configuration(config_values) - - if success: - self._show_snackbar("Custom instructions saved successfully!") - else: - self._show_snackbar("Failed to save custom instructions", error=True) - - def clear_custom_instructions(self, e): - """Clear custom instructions""" - if self.custom_instructions_ref.current: - self.custom_instructions_ref.current.value = "" - self.page.update() - - def _create_github_resource(self, e): - """Create GitHub resource (PR or Issue)""" - # Implementation would handle GitHub resource creation - self._show_snackbar("Creating GitHub resource...") - - def _create_new_item(self, e): - """Create new PR/Issue""" - # Implementation for creating new items - pass - - def _select_current_item(self, e): - """Set selected item as current from table""" - # Implementation to set current item from table selection - pass - - def find_and_load_diff_files(self, e): - """Find and load .diff files""" - # Implementation to find and load diff files - pass - - def clear_diff_display(self, e): - """Clear the diff display""" - if self.diff_text_ref.current: - self.diff_text_ref.current.value = "" - self.page.update() - - # ===== Async Operations ===== - - async def _auto_load_cached_items(self): - """Auto-load cached items on startup if available""" - print("=" * 60) - print("šŸ”„ Auto-loading cached items on startup...") - print("=" * 60) - - def load_cached(): - try: - # Get configured repos - target_repo = self.target_repo_dropdown_ref.current.value if self.target_repo_dropdown_ref.current else None - forked_repo = self.forked_repo_dropdown_ref.current.value if self.forked_repo_dropdown_ref.current else None - - if not target_repo and not forked_repo: - print("No repositories configured, skipping auto-load") - return - - github_token = self.config_manager.get_config().get('GITHUB_PAT', '') - if not github_token: - print("No GitHub token configured, skipping auto-load") - return - - items_loaded = False - - # Try to load target repo items from cache - if target_repo and not target_repo.startswith('---') and '/' in target_repo: - cached_prs = self.cache_manager.load_from_cache('target_prs', target_repo) if self.cache_manager else None - cached_issues = self.cache_manager.load_from_cache('target_issues', target_repo) if self.cache_manager else None - - if cached_prs is not None: - from .workflow import WorkflowItem - self.workflow_items['target_prs'] = [WorkflowItem.from_dict(item) for item in cached_prs] - print(f"āœ“ Auto-loaded {len(cached_prs)} PRs from cache (target)") - if self.logger: - self.logger.log(f"āœ… Auto-loaded {len(cached_prs)} PRs from cache (target)") - items_loaded = True - - if cached_issues is not None: - from .workflow import WorkflowItem - self.workflow_items['target_issues'] = [WorkflowItem.from_dict(item) for item in cached_issues] - print(f"āœ“ Auto-loaded {len(cached_issues)} issues from cache (target)") - if self.logger: - self.logger.log(f"āœ… Auto-loaded {len(cached_issues)} issues from cache (target)") - items_loaded = True - - # Try to load fork repo items from cache - if forked_repo and not forked_repo.startswith('---') and '/' in forked_repo: - cached_fork_prs = self.cache_manager.load_from_cache('fork_prs', forked_repo) if self.cache_manager else None - cached_fork_issues = self.cache_manager.load_from_cache('fork_issues', forked_repo) if self.cache_manager else None - - if cached_fork_prs is not None: - from .workflow import WorkflowItem - self.workflow_items['fork_prs'] = [WorkflowItem.from_dict(item) for item in cached_fork_prs] - print(f"āœ“ Auto-loaded {len(cached_fork_prs)} PRs from cache (fork)") - if self.logger: - self.logger.log(f"āœ… Auto-loaded {len(cached_fork_prs)} PRs from cache (fork)") - items_loaded = True - - if cached_fork_issues is not None: - from .workflow import WorkflowItem - self.workflow_items['fork_issues'] = [WorkflowItem.from_dict(item) for item in cached_fork_issues] - print(f"āœ“ Auto-loaded {len(cached_fork_issues)} issues from cache (fork)") - if self.logger: - self.logger.log(f"āœ… Auto-loaded {len(cached_fork_issues)} issues from cache (fork)") - items_loaded = True - - if items_loaded: - # Filter and update UI - self.page.run_task(self._filter_workflow_items_async) - - # Populate all items list in sidebar - self._populate_all_items() - - print("āœ… Auto-load completed successfully") - else: - print("No cached items found, waiting for manual load") - - except Exception as e: - print(f"Error during auto-load: {e}") - if self.logger: - self.logger.log(f"Error during auto-load: {e}") - - await asyncio.to_thread(load_cached) - - async def _auto_load_cached_items_on_repo_change(self): - """Auto-load cached items when repository selection changes""" - print("šŸ”„ Repository changed - checking for cached items...") - - def load_cached(): - try: - # Get configured repos - target_repo = self.target_repo_dropdown_ref.current.value if self.target_repo_dropdown_ref.current else None - forked_repo = self.forked_repo_dropdown_ref.current.value if self.forked_repo_dropdown_ref.current else None - - github_token = self.config_manager.get_config().get('GITHUB_PAT', '') - if not github_token: - print("No GitHub token configured") - return - - items_loaded = False - - # Try to load target repo items from cache - if target_repo and not target_repo.startswith('---') and '/' in target_repo: - cached_prs = self.cache_manager.load_from_cache('target_prs', target_repo) if self.cache_manager else None - cached_issues = self.cache_manager.load_from_cache('target_issues', target_repo) if self.cache_manager else None - - if cached_prs is not None: - from .workflow import WorkflowItem - self.workflow_items['target_prs'] = [WorkflowItem.from_dict(item) for item in cached_prs] - print(f"āœ“ Loaded {len(cached_prs)} cached PRs for target: {target_repo}") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_prs)} cached PRs for target: {target_repo}") - items_loaded = True - - if cached_issues is not None: - from .workflow import WorkflowItem - self.workflow_items['target_issues'] = [WorkflowItem.from_dict(item) for item in cached_issues] - print(f"āœ“ Loaded {len(cached_issues)} cached issues for target: {target_repo}") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_issues)} cached issues for target: {target_repo}") - items_loaded = True - - # Try to load fork repo items from cache - if forked_repo and not forked_repo.startswith('---') and '/' in forked_repo: - cached_fork_prs = self.cache_manager.load_from_cache('fork_prs', forked_repo) if self.cache_manager else None - cached_fork_issues = self.cache_manager.load_from_cache('fork_issues', forked_repo) if self.cache_manager else None - - if cached_fork_prs is not None: - from .workflow import WorkflowItem - self.workflow_items['fork_prs'] = [WorkflowItem.from_dict(item) for item in cached_fork_prs] - print(f"āœ“ Loaded {len(cached_fork_prs)} cached PRs for fork: {forked_repo}") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_fork_prs)} cached PRs for fork: {forked_repo}") - items_loaded = True - - if cached_fork_issues is not None: - from .workflow import WorkflowItem - self.workflow_items['fork_issues'] = [WorkflowItem.from_dict(item) for item in cached_fork_issues] - print(f"āœ“ Loaded {len(cached_fork_issues)} cached issues for fork: {forked_repo}") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_fork_issues)} cached issues for fork: {forked_repo}") - items_loaded = True - - if items_loaded: - # Filter and update UI - self.page.run_task(self._filter_workflow_items_async) - - # Populate all items list in sidebar - self._populate_all_items() - - print("āœ… Cached items loaded for selected repositories") - if self.logger: - self.logger.log("āœ… Cached items loaded for selected repositories") - else: - print("No cached items found for selected repositories") - - except Exception as e: - print(f"Error loading cached items on repo change: {e}") - if self.logger: - self.logger.log(f"Error loading cached items on repo change: {e}") - - await asyncio.to_thread(load_cached) - - async def _load_custom_instructions(self): - """Load custom instructions from config""" - try: - config = self.config_manager.get_config() - instructions = config.get('CUSTOM_INSTRUCTIONS', '') - - if self.custom_instructions_ref.current: - self.custom_instructions_ref.current.value = instructions - self.page.update() - except Exception as e: - print(f"Error loading custom instructions: {e}") - - async def _init_load_repos(self): - """Initialize repository loading""" - await self._load_target_repos_async() - await self._load_forked_repos_async() - - async def _load_target_repos_async(self): - """Load target repositories""" - def load_repos(): - try: - github_token = self.config_manager.get_config().get('GITHUB_PAT', '') - if not github_token: - return - - from .workflow import GitHubRepoFetcher - repo_fetcher = GitHubRepoFetcher(github_token, self.logger) - repos = repo_fetcher.fetch_repos_with_permissions(min_permission='push') - self.target_repos = repo_fetcher.get_repo_names(repos) - - # Update UI - if self.target_repo_dropdown_ref.current: - self.page.run_task(self._update_target_dropdown_async) - - except Exception as e: - if self.logger: - self.logger.log(f"Error loading target repos: {e}") - - await asyncio.to_thread(load_repos) - - async def _update_target_dropdown_async(self): - """Update target repository dropdown""" - if not self.target_repo_dropdown_ref.current: - return - - options = [] - if self.target_repos: - options.append(ft.dropdown.Option("--- Your Repos (with edit access) ---", disabled=True)) - options.extend([ft.dropdown.Option(repo) for repo in self.target_repos]) - - self.target_repo_dropdown_ref.current.options = options - - # Set value from saved settings - saved_repo = self.config_manager.get_config().get('GITHUB_REPO', '') - if saved_repo: - self.target_repo_dropdown_ref.current.value = saved_repo - - self.page.update() - - async def _refresh_target_repos_async(self): - """Refresh target repositories""" - await self._load_target_repos_async() - - async def _search_target_repos_async(self): - """Search for repositories on GitHub""" - # Create search dialog - search_input = ft.TextField( - label="Search for repository", - hint_text="Enter owner/repo or search term", - expand=True, - autofocus=True, - ) - - results_list = ft.ListView( - expand=True, - spacing=5, - padding=10, - ) - - def perform_search(e): - search_term = search_input.value.strip() - if not search_term: - return - - # Clear previous results - results_list.controls.clear() - results_list.controls.append( - ft.Text("Searching...", color=ft.colors.GREY_400, italic=True) - ) - self.page.update() - - # Search GitHub - try: - github_token = self.config_manager.get_config().get('GITHUB_PAT', '') - if not github_token: - results_list.controls.clear() - results_list.controls.append( - ft.Text("GitHub token not configured", color=ft.colors.RED) - ) - self.page.update() - return - - from .workflow import GitHubRepoFetcher - repo_fetcher = GitHubRepoFetcher(github_token, self.logger) - - # Check if it's a direct repo reference (owner/repo) - if '/' in search_term and len(search_term.split('/')) == 2: - # Try to get the specific repo - repos = repo_fetcher.search_repositories(search_term, per_page=1) - if repos: - results_list.controls.clear() - for repo in repos: - repo_name = repo_fetcher.get_repo_names([repo])[0] if repo_fetcher.get_repo_names([repo]) else None - if repo_name: - results_list.controls.append( - self._create_repo_result_item(repo_name, repo, search_dialog) - ) - else: - results_list.controls.clear() - results_list.controls.append( - ft.Text("Repository not found or you don't have access", color=ft.colors.ORANGE) - ) - else: - # Search for repos - repos = repo_fetcher.search_repositories(search_term, per_page=10) - results_list.controls.clear() - - if repos: - for repo in repos: - repo_name = repo_fetcher.get_repo_names([repo])[0] if repo_fetcher.get_repo_names([repo]) else None - if repo_name: - results_list.controls.append( - self._create_repo_result_item(repo_name, repo, search_dialog) - ) - else: - results_list.controls.append( - ft.Text("No repositories found", color=ft.colors.GREY_400) - ) - - self.page.update() - - except Exception as ex: - results_list.controls.clear() - results_list.controls.append( - ft.Text(f"Error searching: {str(ex)}", color=ft.colors.RED) - ) - self.page.update() - - # Create dialog - def close_dialog(e): - self.page.close(search_dialog) - - search_dialog = ft.AlertDialog( - modal=True, - title=ft.Text("Search GitHub Repositories"), - content=ft.Container( - content=ft.Column([ - ft.Row([ - search_input, - ft.IconButton( - icon=ft.icons.SEARCH, - tooltip="Search", - on_click=perform_search, - ), - ]), - ft.Divider(), - results_list, - ], spacing=10), - width=600, - height=400, - ), - actions=[ - ft.TextButton("Cancel", on_click=close_dialog), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - # Handle Enter key in search input - search_input.on_submit = perform_search - - self.page.open(search_dialog) - - def _create_repo_result_item(self, repo_name, repo_data, dialog): - """Create a repository result item""" - # Get repo description - description = repo_data.get('description', 'No description') - if not description: - description = 'No description' - - # Get visibility - is_private = repo_data.get('private', False) - visibility_text = "Private" if is_private else "Public" - visibility_color = ft.colors.ORANGE if is_private else ft.colors.GREEN - - def select_repo(e): - # Add to dropdown options if not already there - if self.target_repo_dropdown_ref.current: - current_options = [opt.key for opt in self.target_repo_dropdown_ref.current.options] - if repo_name not in current_options: - self.target_repo_dropdown_ref.current.options.append( - ft.dropdown.Option(repo_name) - ) - - # Select this repo - self.target_repo_dropdown_ref.current.value = repo_name - - # Save to config - config = self.config_manager.get_config() - config['GITHUB_REPO'] = repo_name - self.config_manager.save_configuration(config) - - self.page.update() - - # Close dialog - self.page.close(dialog) - self._show_snackbar(f"Selected repository: {repo_name}", error=False) - - return ft.Container( - content=ft.Column([ - ft.Row([ - ft.Text(repo_name, weight=ft.FontWeight.BOLD, size=14), - ft.Container( - content=ft.Text(visibility_text, size=10, color=ft.colors.WHITE), - bgcolor=visibility_color, - padding=ft.padding.symmetric(horizontal=8, vertical=2), - border_radius=4, - ), - ], spacing=10), - ft.Text(description, size=12, color=ft.colors.GREY_400), - ], spacing=5), - padding=10, - border=ft.border.all(1, ft.colors.OUTLINE), - border_radius=4, - bgcolor=ft.colors.GREY_800, - on_click=select_repo, - ink=True, - ) - - async def _load_forked_repos_async(self): - """Load forked repositories""" - def load_forks(): - try: - # Load local repos - local_repo_path = self.config_manager.get_config().get('LOCAL_REPO_PATH', '') - if local_repo_path: - try: - from .utils import LocalRepositoryScanner - self.forked_repos['local'] = LocalRepositoryScanner.scan_local_repos(local_repo_path) - except Exception as e: - print(f"Error scanning local repos: {e}") - - # Load GitHub repos - github_token = self.config_manager.get_config().get('GITHUB_PAT', '') - if github_token: - from .workflow import GitHubRepoFetcher - repo_fetcher = GitHubRepoFetcher(github_token, self.logger) - repos = repo_fetcher.fetch_user_repos(repo_type='owner') - self.forked_repos['github'] = repo_fetcher.get_repo_names(repos) - - # Update UI - if self.forked_repo_dropdown_ref.current: - self.page.run_task(self._update_forked_dropdown_async) - - except Exception as e: - if self.logger: - self.logger.log(f"Error loading forked repos: {e}") - - await asyncio.to_thread(load_forks) - - async def _update_forked_dropdown_async(self): - """Update forked repository dropdown""" - if not self.forked_repo_dropdown_ref.current: - return - - options = [] - - # Add local repos - if self.forked_repos.get('local'): - options.append(ft.dropdown.Option("--- Local Repositories ---", disabled=True)) - options.extend([ft.dropdown.Option(repo) for repo in self.forked_repos['local']]) - - # Add GitHub repos - if self.forked_repos.get('github'): - options.append(ft.dropdown.Option("--- Your GitHub Repos ---", disabled=True)) - options.extend([ft.dropdown.Option(repo) for repo in self.forked_repos['github']]) - - self.forked_repo_dropdown_ref.current.options = options - - # Set value from saved settings - saved_repo = self.config_manager.get_config().get('FORKED_REPO', '') - if saved_repo: - self.forked_repo_dropdown_ref.current.value = saved_repo - - self.page.update() - - async def _refresh_forked_repos_async(self): - """Refresh forked repositories""" - await self._load_forked_repos_async() - - def _clone_forked_repo(self, e): - """Clone forked repository""" - # Implementation would clone the selected repo - pass - - async def _load_workflow_items_async(self): - """Load workflow items (PRs/Issues)""" - # Check if items are already loaded to determine if this is a refresh - items_already_loaded = any(len(items) > 0 for items in self.workflow_items.values()) - force_refresh = items_already_loaded - - if force_refresh: - print("=" * 60) - print("šŸ”„ Refreshing Items (forcing API fetch)...") - print("=" * 60) - if self.logger: - self.logger.log("=" * 60) - self.logger.log("šŸ”„ Refreshing Items - forcing fresh fetch from GitHub API") - self.logger.log("=" * 60) - else: - print("=" * 60) - print("šŸ”„ Load Items button clicked!") - print("=" * 60) - if self.logger: - self.logger.log("=" * 60) - self.logger.log("šŸ”„ Load Items button clicked - starting workflow item load") - self.logger.log("=" * 60) - - def load_items(): - try: - print(f"DEBUG: target_repo_dropdown exists: {self.target_repo_dropdown_ref.current is not None}") - print(f"DEBUG: forked_repo_dropdown exists: {self.forked_repo_dropdown_ref.current is not None}") - - if self.target_repo_dropdown_ref.current: - print(f"DEBUG: target_repo value = '{self.target_repo_dropdown_ref.current.value}'") - if self.forked_repo_dropdown_ref.current: - print(f"DEBUG: forked_repo value = '{self.forked_repo_dropdown_ref.current.value}'") - - if not self.target_repo_dropdown_ref.current and not self.forked_repo_dropdown_ref.current: - if self.logger: - self.logger.log("āŒ No repositories dropdown controls found") - print("ERROR: No repo dropdowns found!") - return - - github_token = self.config_manager.get_config().get('GITHUB_PAT', '') - if not github_token: - if self.logger: - self.logger.log("āŒ No GitHub token configured") - print("ERROR: No GitHub token!") - return - - from .workflow import WorkflowManager - workflow_manager = WorkflowManager(github_token, self.logger) - - # Load from target repo - target_repo = self.target_repo_dropdown_ref.current.value if self.target_repo_dropdown_ref.current else None - print(f"DEBUG: target_repo extracted = '{target_repo}'") - print(f"DEBUG: Validation checks:") - print(f" - target_repo is not None: {target_repo is not None}") - print(f" - not starts with '---': {not target_repo.startswith('---') if target_repo else 'N/A'}") - print(f" - contains '/': {'/' in target_repo if target_repo else 'N/A'}") - - # Filter out separator headers and None values - if target_repo and not target_repo.startswith('---') and '/' in target_repo: - print(f"āœ“ Validation PASSED for target repo: {target_repo}") - if self.logger: - self.logger.log(f"šŸ“„ Loading PRs and issues from target repo: {target_repo}") - - # Try to load from cache first (unless forcing refresh) - cached_prs = None if force_refresh else (self.cache_manager.load_from_cache('target_prs', target_repo) if self.cache_manager else None) - cached_issues = None if force_refresh else (self.cache_manager.load_from_cache('target_issues', target_repo) if self.cache_manager else None) - - if cached_prs is not None and not force_refresh: - # Convert cached dicts back to WorkflowItem objects - from .workflow import WorkflowItem - self.workflow_items['target_prs'] = [WorkflowItem.from_dict(item) for item in cached_prs] - print(f"āœ“ Loaded {len(cached_prs)} PRs from cache") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_prs)} PRs from cache") - else: - print(f"Calling workflow_manager.fetch_pull_requests('{target_repo}')...") - self.workflow_items['target_prs'] = workflow_manager.fetch_pull_requests(target_repo, repo_source='target') - # Convert to dicts and save to cache - if self.cache_manager: - items_as_dicts = [item.to_dict() for item in self.workflow_items['target_prs']] - self.cache_manager.save_to_cache('target_prs', target_repo, items_as_dicts) - - if cached_issues is not None and not force_refresh: - # Convert cached dicts back to WorkflowItem objects - from .workflow import WorkflowItem - self.workflow_items['target_issues'] = [WorkflowItem.from_dict(item) for item in cached_issues] - print(f"āœ“ Loaded {len(cached_issues)} issues from cache") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_issues)} issues from cache") - else: - print(f"Calling workflow_manager.fetch_issues('{target_repo}')...") - self.workflow_items['target_issues'] = workflow_manager.fetch_issues(target_repo, repo_source='target') - # Convert to dicts and save to cache - if self.cache_manager: - items_as_dicts = [item.to_dict() for item in self.workflow_items['target_issues']] - self.cache_manager.save_to_cache('target_issues', target_repo, items_as_dicts) - - pr_count = len(self.workflow_items.get('target_prs', [])) - issue_count = len(self.workflow_items.get('target_issues', [])) - print(f"āœ“ Loaded {pr_count} PRs and {issue_count} issues from target repo") - - if self.logger: - self.logger.log(f"āœ… Loaded {pr_count} PRs and {issue_count} issues from target repo") - else: - print(f"āœ— Validation FAILED for target repo: {target_repo}") - - # Load from forked repo - forked_repo = self.forked_repo_dropdown_ref.current.value if self.forked_repo_dropdown_ref.current else None - # Filter out separator headers and None values - if forked_repo and not forked_repo.startswith('---') and '/' in forked_repo: - if self.logger: - self.logger.log(f"Loading PRs and issues from forked repo: {forked_repo}") - - # Try to load from cache first (unless forcing refresh) - cached_fork_prs = None if force_refresh else (self.cache_manager.load_from_cache('fork_prs', forked_repo) if self.cache_manager else None) - cached_fork_issues = None if force_refresh else (self.cache_manager.load_from_cache('fork_issues', forked_repo) if self.cache_manager else None) - - if cached_fork_prs is not None and not force_refresh: - # Convert cached dicts back to WorkflowItem objects - from .workflow import WorkflowItem - self.workflow_items['fork_prs'] = [WorkflowItem.from_dict(item) for item in cached_fork_prs] - print(f"āœ“ Loaded {len(cached_fork_prs)} PRs from cache (fork)") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_fork_prs)} PRs from cache (fork)") - else: - self.workflow_items['fork_prs'] = workflow_manager.fetch_pull_requests(forked_repo, repo_source='fork') - # Convert to dicts and save to cache - if self.cache_manager: - items_as_dicts = [item.to_dict() for item in self.workflow_items['fork_prs']] - self.cache_manager.save_to_cache('fork_prs', forked_repo, items_as_dicts) - - if cached_fork_issues is not None and not force_refresh: - # Convert cached dicts back to WorkflowItem objects - from .workflow import WorkflowItem - self.workflow_items['fork_issues'] = [WorkflowItem.from_dict(item) for item in cached_fork_issues] - print(f"āœ“ Loaded {len(cached_fork_issues)} issues from cache (fork)") - if self.logger: - self.logger.log(f"āœ… Loaded {len(cached_fork_issues)} issues from cache (fork)") - else: - self.workflow_items['fork_issues'] = workflow_manager.fetch_issues(forked_repo, repo_source='fork') - # Convert to dicts and save to cache - if self.cache_manager: - items_as_dicts = [item.to_dict() for item in self.workflow_items['fork_issues']] - self.cache_manager.save_to_cache('fork_issues', forked_repo, items_as_dicts) - - if self.logger: - self.logger.log(f"Loaded {len(self.workflow_items.get('fork_prs', []))} PRs and {len(self.workflow_items.get('fork_issues', []))} issues from forked repo") - - # Filter and update UI - self.page.run_task(self._filter_workflow_items_async) - - # Populate all items list in sidebar - self._populate_all_items() - - except Exception as e: - if self.logger: - self.logger.log(f"Error loading workflow items: {e}") - import traceback - self.logger.log(traceback.format_exc()) - - await asyncio.to_thread(load_items) - - async def _filter_workflow_items_async(self): - """Filter workflow items async""" - self._filter_workflow_items() - - # ===== Helper Methods ===== - - def _display_current_item(self): - """Display the current work item""" - if not self.current_work_items or self.current_item_index >= len(self.current_work_items): - return - - item = self.current_work_items[self.current_item_index] - - # Update UI fields - if self.work_item_id_ref.current: - self.work_item_id_ref.current.value = f"Work Item {item.get('id', 'N/A')}" - - if self.nature_text_ref.current: - self.nature_text_ref.current.value = item.get('nature', '') - - if self.live_doc_url_ref.current: - self.live_doc_url_ref.current.value = item.get('live_doc_url', '') - - if self.text_to_change_ref.current: - self.text_to_change_ref.current.value = item.get('old_text', '') - - if self.proposed_new_text_ref.current: - self.proposed_new_text_ref.current.value = item.get('new_text', '') - - self.page.update() - self._update_navigation_buttons() - - def update_status(self, message: str): - """Update status message""" - if self.status_text_ref.current: - self.status_text_ref.current.value = message - self.page.update() - - def _show_progress(self): - """Show progress bar""" - if self.progress_bar_ref.current: - self.progress_bar_ref.current.visible = True - self.page.update() - - def _hide_progress(self): - """Hide progress bar""" - if self.progress_bar_ref.current: - self.progress_bar_ref.current.visible = False - self.page.update() - - def _show_snackbar(self, message: str, error: bool = False): - """Show snackbar notification""" - self.page.snack_bar = ft.SnackBar( - content=ft.Text(message), - bgcolor="error" if error else "green", - ) - self.page.snack_bar.open = True - self.page.update() - - def _open_settings(self, e): - """Open settings dialog""" - try: - print("Settings button clicked!") - - # Use Flet 0.28+ API: page.open() instead of page.dialog - config = self.config_manager.get_config() - print(f"Got config: {config.keys() if config else 'None'}") - - settings_dialog = SettingsDialog( - self.page, - config, - self.config_manager, - self.cache_manager - ) - print("SettingsDialog created") - - def on_settings_result(result): - if result: - # Reload configuration - self.config_manager.load_configuration() - self._show_snackbar("Settings saved successfully!") - - print("Calling settings_dialog.show()...") - settings_dialog.show(on_result=on_settings_result) - print("settings_dialog.show() completed") - - except Exception as ex: - print(f"Error in _open_settings: {ex}") - import traceback - traceback.print_exc() - self._show_snackbar(f"Error opening settings: {ex}", error=True) - - def _open_processing_log(self, e): - """Open processing log dialog""" - try: - print("Processing Log button clicked!") - - processing_log_dialog = ProcessingLogDialog( - self.page, - self.log_text_ref - ) - print("ProcessingLogDialog created") - - processing_log_dialog.show() - print("ProcessingLogDialog.show() completed") - - except Exception as ex: - print(f"Error in _open_processing_log: {ex}") - import traceback - traceback.print_exc() - self._show_snackbar(f"Error opening processing log: {ex}", error=True) - - def _show_real_settings(self): - """Show the real settings dialog""" - try: - config = self.config_manager.get_config() - print(f"Got config: {config.keys() if config else 'None'}") - - settings_dialog = SettingsDialog( - self.page, - config, - self.config_manager, - self.cache_manager - ) - print("SettingsDialog created") - - def on_settings_result(result): - if result: - # Reload configuration - self.config_manager.load_configuration() - self._show_snackbar("Settings saved successfully!") - - print("Calling settings_dialog.show()...") - settings_dialog.show(on_result=on_settings_result) - print("settings_dialog.show() completed") - except Exception as ex: - print(f"Error in _show_real_settings: {ex}") - import traceback - traceback.print_exc() - self._show_snackbar(f"Error showing settings: {ex}", error=True) - - def _check_ai_modules_manual(self, e): - """Manually check AI modules""" - config = self.config_manager.get_config() - ai_provider = config.get('AI_PROVIDER', 'none').lower() - - if ai_provider and ai_provider != 'none': - self.page.run_task(lambda: self._check_ai_provider_async(ai_provider)) - else: - self._show_snackbar("No AI provider configured") - - async def _check_ai_provider_async(self, ai_provider: str): - """Check AI provider setup""" - try: - available, missing = self.ai_manager.check_ai_module_availability(ai_provider) - - if available: - self._show_snackbar(f"AI Provider ({ai_provider}): All modules available!") - else: - self._show_snackbar( - f"AI Provider ({ai_provider}): Missing packages: {', '.join(missing)}", - error=True - ) - except Exception as e: - self._show_snackbar(f"Error checking AI provider: {e}", error=True) - - def update_diff_display(self, diff_content: str): - """Update diff display""" - if self.diff_text_ref.current: - self.diff_text_ref.current.value = diff_content - 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: - """Logger class for Flet""" - - def __init__(self, text_field: ft.TextField): - self.text_field = text_field - - def log(self, message: str): - """Log a message""" - import datetime - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - log_message = f"[{timestamp}] {message}\n" - - if self.text_field: - current = self.text_field.value or "" - self.text_field.value = current + log_message - # Auto-scroll is handled by Flet diff --git a/src/app_components/processing_log_dialog.py b/src/app_components/processing_log_dialog.py deleted file mode 100644 index 998549f..0000000 --- a/src/app_components/processing_log_dialog.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Processing Log Dialog -Displays the processing log in a separate dialog window -""" - -import flet as ft -# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors) -ft.icons = ft.Icons -ft.colors = ft.Colors - - -class ProcessingLogDialog: - """Processing log display dialog""" - - def __init__(self, page: ft.Page, log_text_ref: ft.Ref): - self.page = page - self.log_text_ref = log_text_ref - self.dialog_ref = ft.Ref[ft.AlertDialog]() - self.log_display_ref = ft.Ref[ft.TextField]() - - def show(self): - """Show the processing log dialog""" - try: - print("ProcessingLogDialog.show() called") - - # Create the dialog - dialog = self._create_dialog() - self.dialog_ref.current = dialog - - # Sync the log content before showing - self._sync_log_content() - - # Open the dialog - self.page.open(dialog) - self.page.update() - - except Exception as ex: - print(f"Error in ProcessingLogDialog.show(): {ex}") - import traceback - traceback.print_exc() - - def _sync_log_content(self): - """Sync log content from main log to dialog display""" - if self.log_text_ref.current and self.log_display_ref.current: - self.log_display_ref.current.value = self.log_text_ref.current.value - if self.page: - self.page.update() - - def _create_dialog(self) -> ft.AlertDialog: - """Create the processing log dialog""" - # Create a display field that will show a copy of the log - # This is synced from the main log field - log_display = ft.TextField( - ref=self.log_display_ref, - value=self.log_text_ref.current.value if self.log_text_ref.current else "", - multiline=True, - read_only=True, - expand=True, - text_style=ft.TextStyle(font_family="Courier New"), - min_lines=20, - max_lines=30, - ) - - # Refresh button - refresh_button = ft.TextButton( - "Refresh", - icon=ft.icons.REFRESH, - on_click=self._refresh_log, - ) - - # Clear button - clear_button = ft.TextButton( - "Clear Log", - icon=ft.icons.DELETE_OUTLINE, - on_click=self._clear_log, - ) - - # Close button - close_button = ft.TextButton( - "Close", - on_click=self._close_clicked, - ) - - dialog = ft.AlertDialog( - ref=self.dialog_ref, - modal=True, - title=ft.Row( - [ - ft.Icon(ft.icons.LIST_ALT, color="blue"), - ft.Text("Processing Log", size=20, weight=ft.FontWeight.BOLD), - ], - alignment=ft.MainAxisAlignment.START, - ), - content=ft.Container( - content=log_display, - width=800, - height=500, - ), - actions=[ - refresh_button, - clear_button, - close_button, - ], - actions_alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - ) - - return dialog - - def _refresh_log(self, e): - """Refresh the log content from the main log""" - self._sync_log_content() - - def _clear_log(self, e): - """Clear the log""" - # Clear both the main log and the display - if self.log_text_ref.current: - self.log_text_ref.current.value = "" - if self.log_display_ref.current: - self.log_display_ref.current.value = "" - self.page.update() - - def _close_clicked(self, e): - """Handle close button click""" - if self.dialog_ref.current: - self.page.close(self.dialog_ref.current) diff --git a/src/app_components/settings_dialog.py b/src/app_components/settings_dialog.py deleted file mode 100644 index ea53efe..0000000 --- a/src/app_components/settings_dialog.py +++ /dev/null @@ -1,1150 +0,0 @@ -""" -Settings Dialog -GUI for configuring application settings (Flet version) -""" - -import flet as ft -# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors) -ft.icons = ft.Icons -ft.colors = ft.Colors -from typing import Dict, Any, Optional, List, Tuple -import os -import asyncio -import sys -import subprocess - - -class SettingsDialog: - """Settings configuration dialog""" - - def __init__(self, page: ft.Page, config: Dict[str, Any], config_manager=None, cache_manager=None): - self.page = page - self.config = config.copy() - self.config_manager = config_manager - self.cache_manager = cache_manager - self.result = None - self.entries = {} - self.dialog_ref = ft.Ref[ft.AlertDialog]() - - # Dropdown refs - self.detected_repos_dropdown_ref = ft.Ref[ft.Dropdown]() - self.ollama_model_dropdown_ref = ft.Ref[ft.Dropdown]() - - # Package checker refs - self.package_status_ref = ft.Ref[ft.Container]() - - def show(self, on_result=None): - """Show the settings dialog""" - try: - print("SettingsDialog.show() called") - self.on_result = on_result - - # Create the dialog - print("Creating dialog...") - dialog = self._create_dialog() - print(f"Dialog created: {dialog}") - - # Always set the dialog ref to the current dialog instance - print("Setting dialog_ref.current to new dialog instance") - self.dialog_ref.current = dialog - - # Use Flet 0.28+ API: page.open() instead of page.dialog - print("Opening dialog with page.open()...") - self.page.open(dialog) - # Ensure UI updates immediately (useful when console is not visible) - try: - self.page.update() - except Exception: - pass - print("page.open() completed") - - # Start async initialization - print("Starting async initialization...") - self.page.run_task(self._init_async) - print("SettingsDialog.show() completed") - except Exception as ex: - print(f"Error in SettingsDialog.show(): {ex}") - import traceback - traceback.print_exc() - - async def _init_async(self): - """Initialize async operations""" - await asyncio.sleep(0.1) - await self._scan_repos_async() - # Load cached Ollama models - await self._load_cached_ollama_models() - # Check packages for current AI provider - await self._check_packages_for_current_provider() - - def _create_dialog(self) -> ft.AlertDialog: - """Create the settings dialog""" - # Create tabs - tabs = ft.Tabs( - selected_index=0, - animation_duration=300, - tabs=[ - ft.Tab( - text="General", - icon=ft.icons.SETTINGS, - content=self._create_general_tab() - ), - ft.Tab( - text="AI Providers", - icon=ft.icons.PSYCHOLOGY, - content=self._create_ai_tab() - ), - ], - expand=True, - ) - - # Action buttons - actions = ft.Row( - [ - ft.TextButton( - "Test Connection", - icon=ft.icons.CABLE, - on_click=self._test_connection - ), - ft.TextButton( - "Clear Cache", - icon=ft.icons.DELETE_SWEEP, - on_click=self._clear_cache - ), - ft.Container(expand=True), - ft.TextButton("Cancel", on_click=self._cancel_clicked), - ft.FilledButton("Save Settings", icon=ft.icons.SAVE, on_click=self._save_clicked), - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - ) - - dialog = ft.AlertDialog( - ref=self.dialog_ref, - modal=True, - title=ft.Text("āš™ļø Settings", size=24, weight=ft.FontWeight.BOLD), - content=ft.Container( - content=tabs, - width=900, - height=700, - padding=10, - ), - actions=[actions], - actions_padding=ft.padding.all(20), - ) - - return dialog - - def _create_general_tab(self) -> ft.Container: - """Create general settings tab""" - controls = [] - - # GitHub Configuration Section - controls.append(self._create_section_header("šŸ™ GitHub Personal Access Token")) - - # GitHub PAT - github_pat = ft.TextField( - label="Personal Access Token", - password=True, - can_reveal_password=True, - value=self.config.get('GITHUB_PAT', ''), - hint_text="Enter your GitHub Personal Access Token", - expand=True, - ) - self.entries['GITHUB_PAT'] = github_pat - controls.append(github_pat) - - # General Options Section - controls.append(self._create_section_header("āš™ļø General Options")) - - # Dry Run Mode - dry_run_checkbox = ft.Checkbox( - label="🧪 Dry Run Mode (Test without making changes)", - value=str(self.config.get('DRY_RUN', 'false')).lower() in ('true', '1', 'yes', 'on'), - ) - self.entries['DRY_RUN'] = dry_run_checkbox - controls.append(dry_run_checkbox) - controls.append(ft.Text( - "ā„¹ļø Simulates operations without creating actual GitHub issues/PRs", - size=12, - color="grey400", - )) - - # Local Repo Path - local_repo_path = ft.TextField( - label="Local Repo Path", - value=self.config.get('LOCAL_REPO_PATH', ''), - hint_text="Path where repositories are cloned", - expand=True, - ) - self.entries['LOCAL_REPO_PATH'] = local_repo_path - controls.append(local_repo_path) - - # Detected Repos - controls.append(ft.Text("Detected Repos", weight=ft.FontWeight.BOLD, size=14)) - detected_repos_row = ft.Row( - [ - ft.Dropdown( - ref=self.detected_repos_dropdown_ref, - label="Detected Repositories", - value="Scanning...", - options=[], - hint_text="Scanned local repositories", - expand=True, - ), - ft.ElevatedButton( - "šŸ”„ Scan", - on_click=lambda e: self.page.run_task(self._scan_repos_async), - ), - ], - spacing=5, - ) - controls.append(detected_repos_row) - - # Help text - controls.append(ft.Container( - content=ft.Text( - "šŸ’” Repository Setup Guide:\n" - " • Local Repo Path: Where your fork repos are cloned (e.g., C:\\git\\repos)\n" - " • Detected Repos: Shows your local fork (e.g., yourname/repo)\n" - " • Note: Target and Fork repositories are configured in the main GUI", - size=12, - color="grey400", - ), - padding=ft.padding.all(10), - bgcolor="surfacevariant", - border_radius=5, - margin=ft.margin.only(top=10), - )) - - controls.append(ft.Container( - content=ft.Text( - "šŸ’” Getting Started:\n" - "1. Create a GitHub Personal Access Token\n" - "2. Configure GitHub repositories in the main GUI\n" - "3. Set Local Repo Path for automatic repository detection\n" - "4. Configure AI provider in the AI tab (optional)\n" - "5. Test your connection before processing items", - size=12, - color="blue400", - ), - padding=ft.padding.all(10), - bgcolor="blue900", - border_radius=5, - margin=ft.margin.only(top=10), - )) - - return ft.Container( - content=ft.ListView( - controls=controls, - spacing=15, - padding=20, - ), - expand=True, - ) - - def _create_ai_tab(self) -> ft.Container: - """Create AI settings tab""" - controls = [] - - # Package Status Section (at the top) - controls.append(ft.Container( - content=ft.Column([ - ft.Row([ - ft.Text("Package Status", size=16, weight=ft.FontWeight.BOLD), - ft.IconButton( - icon=ft.icons.REFRESH, - tooltip="Refresh package status", - on_click=lambda e: self.page.run_task(self._check_packages_for_current_provider), - ), - ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), - ft.Container( - ref=self.package_status_ref, - content=ft.Row([ - ft.ProgressRing(width=20, height=20), - ft.Text("Checking packages...", color=ft.colors.BLUE), - ]), - padding=10, - bgcolor=ft.colors.BLUE_100, - border_radius=5, - ), - ], spacing=10), - padding=ft.padding.only(bottom=10), - )) - - # AI Provider Section - controls.append(self._create_section_header("šŸ¤– AI Provider Configuration")) - - # Provider dropdown - ai_provider = ft.Dropdown( - label="AI Provider", - value=self.config.get('AI_PROVIDER', 'none'), - options=[ - ft.dropdown.Option("none", "None"), - ft.dropdown.Option("claude", "Claude"), - ft.dropdown.Option("chatgpt", "ChatGPT"), - ft.dropdown.Option("github-copilot", "GitHub Copilot"), - ft.dropdown.Option("ollama", "Ollama"), - ], - expand=True, - on_change=lambda e: self.page.run_task(self._check_packages_for_current_provider), - ) - self.entries['AI_PROVIDER'] = ai_provider - controls.append(ai_provider) - - # API Keys - claude_key = ft.TextField( - label="Claude API Key", - password=True, - can_reveal_password=True, - value=self.config.get('CLAUDE_API_KEY', ''), - hint_text="Get key at console.anthropic.com", - expand=True, - ) - self.entries['CLAUDE_API_KEY'] = claude_key - controls.append(claude_key) - - chatgpt_key = ft.TextField( - label="ChatGPT API Key", - password=True, - can_reveal_password=True, - value=self.config.get('OPENAI_API_KEY', ''), - hint_text="Get key at platform.openai.com/api-keys", - expand=True, - ) - self.entries['OPENAI_API_KEY'] = chatgpt_key - controls.append(chatgpt_key) - - github_token = ft.TextField( - label="GitHub Token (for Copilot) [defaults to GitHub PAT]", - password=True, - can_reveal_password=True, - value=self.config.get('GITHUB_TOKEN', ''), - hint_text="Defaults to GitHub PAT if empty", - expand=True, - ) - self.entries['GITHUB_TOKEN'] = github_token - controls.append(github_token) - - # Ollama Configuration - controls.append(self._create_section_header("šŸ¦™ Ollama Configuration")) - - ollama_url = ft.TextField( - label="Ollama Server URL", - value=self.config.get('OLLAMA_URL', ''), - hint_text="http://localhost:11434", - expand=True, - ) - self.entries['OLLAMA_URL'] = ollama_url - controls.append(ollama_url) - - ollama_api_key = ft.TextField( - label="Ollama API Key (optional)", - password=True, - can_reveal_password=True, - value=self.config.get('OLLAMA_API_KEY', ''), - expand=True, - ) - self.entries['OLLAMA_API_KEY'] = ollama_api_key - controls.append(ollama_api_key) - - # Ollama Model - ollama_model = ft.Dropdown( - ref=self.ollama_model_dropdown_ref, - label="Ollama Model", - value=self.config.get('OLLAMA_MODEL', ''), - options=[], - hint_text="Click scan to load models", - expand=True, - ) - self.entries['OLLAMA_MODEL'] = ollama_model - - ollama_model_row = ft.Row( - [ - ollama_model, - ft.ElevatedButton( - "šŸ” Scan", - on_click=lambda e: self.page.run_task(self._scan_ollama_models_async), - ), - ], - spacing=5, - ) - controls.append(ollama_model_row) - controls.append(ft.Text( - "ā„¹ļø Click šŸ” to scan available models from your Ollama server.", - size=12, - color="grey400", - )) - - # Help text - controls.append(ft.Container( - content=ft.Text( - "šŸ’” Tips:\n" - "• Provider: Choose 'none' to disable AI (uses Copilot workflow)\n" - "• Claude: Get key at console.anthropic.com\n" - "• ChatGPT: Get key at platform.openai.com/api-keys\n" - "• GitHub Copilot: Uses GitHub Models API (requires GitHub token)\n" - "• GitHub Token: Auto-defaults to GitHub PAT if left empty\n" - "• Ollama: Self-hosted AI (requires Ollama server running)\n" - "• Cost: ~$0.01-0.05 per PR with AI, free with 'none' and Ollama\n" - "• AI providers clone repos locally to make changes before pushing", - size=12, - color="blue400", - ), - padding=ft.padding.all(10), - bgcolor="blue900", - border_radius=5, - margin=ft.margin.only(top=10), - )) - - return ft.Container( - content=ft.ListView( - controls=controls, - spacing=15, - padding=20, - ), - expand=True, - ) - - def _create_section_header(self, text: str) -> ft.Container: - """Create a section header""" - return ft.Container( - content=ft.Column( - [ - ft.Text(text, size=18, weight=ft.FontWeight.BOLD), - ft.Divider(thickness=2, color="primary"), - ], - spacing=5, - ), - padding=ft.padding.only(top=20, bottom=10), - ) - - def _check_ai_packages(self, provider_name: str) -> Tuple[bool, List[str]]: - """Check if required packages for AI provider are installed""" - try: - from .ai_manager import AIManager - ai_manager = AIManager() - available, missing = ai_manager.check_ai_module_availability(provider_name) - return available, missing - except Exception as e: - print(f"Error checking AI packages: {e}") - return False, [] - - def _detect_environment(self) -> Tuple[bool, str]: - """Detect if running in virtual environment""" - in_venv = (hasattr(sys, 'real_prefix') or - (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) or - os.environ.get('VIRTUAL_ENV') is not None) - - if in_venv: - venv_path = os.environ.get('VIRTUAL_ENV', sys.prefix) - venv_name = os.path.basename(venv_path) - return True, venv_name - else: - return False, "system-wide" - - async def _load_cached_ollama_models(self): - """Load cached Ollama models on dialog open""" - if not self.cache_manager: - return - - try: - # Get Ollama URL to use as cache identifier - ollama_url = self.config.get('OLLAMA_URL', '').strip() - if not ollama_url: - return - - # Load cached models - def load_cache(): - cached_data = self.cache_manager.load_from_cache('ollama_models', ollama_url) - if cached_data: - # Extract model names from cache format - return [item['name'] for item in cached_data if 'name' in item] - return [] - - cached_models = await asyncio.to_thread(load_cache) - - if cached_models and self.ollama_model_dropdown_ref.current: - # Update dropdown with cached models - self.ollama_model_dropdown_ref.current.options = [ - ft.dropdown.Option(model) for model in cached_models - ] - - # Restore saved selection - saved_model = self.config.get('OLLAMA_MODEL', '') - if saved_model and saved_model in cached_models: - self.ollama_model_dropdown_ref.current.value = saved_model - elif cached_models: - # If saved model not in list, select first one - self.ollama_model_dropdown_ref.current.value = cached_models[0] - - self.page.update() - print(f"Loaded {len(cached_models)} cached Ollama models") - - except Exception as e: - print(f"Error loading cached Ollama models: {e}") - - async def _check_packages_for_current_provider(self): - """Check packages for the currently selected AI provider""" - if not self.package_status_ref.current: - return - - # Get current provider selection - ai_provider_dropdown = self.entries.get('AI_PROVIDER') - if not ai_provider_dropdown: - return - - provider = ai_provider_dropdown.value - if not provider or provider == 'none': - self.package_status_ref.current.content = ft.Container( - content=ft.Row([ - ft.Icon(ft.icons.INFO, color=ft.colors.BLUE), - ft.Text("No AI provider selected", color=ft.colors.BLUE), - ]), - padding=10, - bgcolor=ft.colors.BLUE_100, - border_radius=5, - ) - self.page.update() - return - - # Check packages in background thread - def check_packages(): - return self._check_ai_packages(provider) - - available, missing = await asyncio.to_thread(check_packages) - - # Update UI with results - if available: - self.package_status_ref.current.content = ft.Container( - content=ft.Row([ - ft.Icon(ft.icons.CHECK_CIRCLE, color=ft.colors.GREEN), - ft.Text(f"All required packages for {provider} are installed", color=ft.colors.GREEN), - ]), - padding=10, - bgcolor=ft.colors.GREEN_100, - border_radius=5, - ) - else: - in_venv, env_name = self._detect_environment() - env_text = f"Virtual environment: {env_name}" if in_venv else "System-wide installation" - - self.package_status_ref.current.content = ft.Container( - content=ft.Column([ - ft.Row([ - ft.Icon(ft.icons.WARNING, color=ft.colors.ORANGE), - ft.Text(f"Missing packages for {provider}", color=ft.colors.ORANGE, weight=ft.FontWeight.BOLD), - ]), - ft.Text(f"Required: {', '.join(missing)}", size=12), - ft.Text(f"Environment: {env_text}", size=12, italic=True), - ft.ElevatedButton( - "Install Packages", - icon=ft.icons.DOWNLOAD, - on_click=lambda e: self._install_packages(missing, provider), - ), - ], spacing=5), - padding=10, - bgcolor=ft.colors.ORANGE_100, - border_radius=5, - ) - - self.page.update() - - def _install_packages(self, packages: List[str], provider: str): - """Install missing packages""" - in_venv, env_name = self._detect_environment() - env_text = f"virtual environment '{env_name}'" if in_venv else "system-wide (may require administrator rights)" - - # Create confirmation dialog - package_list = ', '.join(packages) - message = (f"Install the following packages for {provider}?\n\n" - f"Packages: {package_list}\n\n" - f"Installation location: {env_text}\n\n" - f"Command: pip install {' '.join(packages)}") - - def handle_install(e): - self.page.close(install_dialog) - # Run installation in background - self.page.run_task(lambda: self._do_install_packages(packages, provider)) - - def handle_cancel(e): - self.page.close(install_dialog) - - install_dialog = ft.AlertDialog( - modal=True, - title=ft.Text("Install AI Packages"), - content=ft.Text(message), - actions=[ - ft.TextButton("Cancel", on_click=handle_cancel), - ft.FilledButton("Install", on_click=handle_install), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - self.page.open(install_dialog) - - async def _do_install_packages(self, packages: List[str], provider: str): - """Actually install the packages""" - in_venv, env_name = self._detect_environment() - - # Update status to show installation in progress - if self.package_status_ref.current: - self.package_status_ref.current.content = ft.Container( - content=ft.Row([ - ft.ProgressRing(width=20, height=20), - ft.Text(f"Installing packages for {provider}...", color=ft.colors.BLUE), - ]), - padding=10, - bgcolor=ft.colors.BLUE_100, - border_radius=5, - ) - self.page.update() - - # Install packages in background thread - def install(): - try: - for package in packages: - print(f"Installing {package}...") - pip_cmd = [sys.executable, '-m', 'pip', 'install', package] - result = subprocess.run(pip_cmd, capture_output=True, text=True, timeout=300) - - # If direct install fails and we're not in venv, try with --user flag - if result.returncode != 0 and not in_venv: - print(f" Direct installation failed, trying with --user flag...") - pip_cmd_user = [sys.executable, '-m', 'pip', 'install', '--user', package] - result = subprocess.run(pip_cmd_user, capture_output=True, text=True, timeout=300) - - if result.returncode != 0: - return False, f"Failed to install {package}: {result.stderr}" - - return True, "All packages installed successfully" - - except subprocess.TimeoutExpired: - return False, "Installation timed out" - except Exception as e: - return False, f"Error installing packages: {str(e)}" - - success, message = await asyncio.to_thread(install) - - # Show result and offer to restart - if success: - def handle_restart(e): - self.page.close(result_dialog) - self._restart_application() - - def handle_later(e): - self.page.close(result_dialog) - # Re-check packages after installation - self.page.run_task(self._check_packages_for_current_provider) - - result_dialog = ft.AlertDialog( - modal=True, - title=ft.Text("Installation Complete"), - content=ft.Text(f"{message}\n\nThe application needs to restart to use the newly installed packages."), - actions=[ - ft.TextButton("Restart Later", on_click=handle_later), - ft.FilledButton("Restart Now", on_click=handle_restart), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - self.page.open(result_dialog) - else: - self._show_alert("Installation Failed", message) - # Re-check packages to update status - await self._check_packages_for_current_provider() - - def _restart_application(self): - """Restart the application""" - try: - # Close the dialog first - if self.dialog_ref.current: - self.page.close(self.dialog_ref.current) - - # Show restart message - restart_msg = ft.SnackBar( - content=ft.Text("Restarting application..."), - bgcolor=ft.colors.BLUE, - ) - self.page.open(restart_msg) - self.page.update() - - # Restart the application - python = sys.executable - os.execl(python, python, *sys.argv) - except Exception as e: - self._show_alert("Restart Failed", f"Could not restart application: {str(e)}\n\nPlease restart manually.") - - async def _install_and_save(self, packages: List[str], provider: str, config_values: Dict[str, Any]): - """Install packages and then save configuration""" - # Install packages - in_venv, _ = self._detect_environment() - - def install(): - try: - for package in packages: - print(f"Installing {package}...") - pip_cmd = [sys.executable, '-m', 'pip', 'install', package] - result = subprocess.run(pip_cmd, capture_output=True, text=True, timeout=300) - - # If direct install fails and we're not in venv, try with --user flag - if result.returncode != 0 and not in_venv: - print(f" Direct installation failed, trying with --user flag...") - pip_cmd_user = [sys.executable, '-m', 'pip', 'install', '--user', package] - result = subprocess.run(pip_cmd_user, capture_output=True, text=True, timeout=300) - - if result.returncode != 0: - return False, f"Failed to install {package}: {result.stderr}" - - return True, "All packages installed successfully" - - except subprocess.TimeoutExpired: - return False, "Installation timed out" - except Exception as e: - return False, f"Error installing packages: {str(e)}" - - success, message = await asyncio.to_thread(install) - - if success: - # Save configuration after successful installation - self._do_save(config_values) - - # Offer to restart - def handle_restart(e): - self.page.close(restart_dialog) - self._restart_application() - - def handle_later(e): - self.page.close(restart_dialog) - - restart_dialog = ft.AlertDialog( - modal=True, - title=ft.Text("Installation Complete"), - content=ft.Text( - f"Packages installed successfully!\n" - f"Settings have been saved.\n\n" - f"The application needs to restart to use the newly installed packages." - ), - actions=[ - ft.TextButton("Restart Later", on_click=handle_later), - ft.FilledButton("Restart Now", on_click=handle_restart), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - self.page.open(restart_dialog) - else: - self._show_alert("Installation Failed", f"{message}\n\nSettings were not saved.") - - def _do_save(self, config_values: Dict[str, Any]): - """Actually save the configuration""" - try: - # Save configuration - if self.config_manager: - success = self.config_manager.save_configuration(config_values) - else: - success = self._save_to_env_file(config_values) - - if success: - self.result = config_values - self._show_alert( - "Settings Saved", - "Settings saved successfully!\n\nChanges applied immediately - no restart needed! ✨" - ) - self._close_dialog() - else: - self._show_alert("Save Error", "Failed to save settings to .env file.") - - except Exception as e: - self._show_alert("Save Error", f"Error saving settings:\n{str(e)}") - - async def _scan_repos_async(self): - """Scan for git repositories in the local repo path""" - try: - from pathlib import Path - - # Get the local repo path - local_path_field = self.entries.get('LOCAL_REPO_PATH') - if local_path_field: - path_str = local_path_field.value.strip() - else: - path_str = self.config.get('LOCAL_REPO_PATH', '').strip() - - if not path_str: - path_str = str(Path.home() / "Downloads" / "github_repos") - - print(f"šŸ” Scanning for repos in: {path_str}") - base_path = Path(path_str) - - if not base_path.exists(): - if self.detected_repos_dropdown_ref.current: - self.detected_repos_dropdown_ref.current.value = 'No repos found (directory does not exist)' - self.detected_repos_dropdown_ref.current.options = [] - self.page.update() - return - - # Scan for git repositories - repos = [] - try: - # First, check if repos are directly in the base path (flat structure) - for item in base_path.iterdir(): - if not item.is_dir(): - continue - - 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(): - continue - - git_dir = repo_dir / ".git" - if git_dir.exists(): - repo_name = f"{item.name}/{repo_dir.name}" - repos.append(repo_name) - - except Exception as e: - print(f"Error scanning repos: {e}") - import traceback - traceback.print_exc() - - # Update dropdown - if self.detected_repos_dropdown_ref.current: - if repos: - repos.sort() - print(f"āœ… Found {len(repos)} repo(s): {', '.join(repos)}") - self.detected_repos_dropdown_ref.current.options = [ - ft.dropdown.Option(repo) for repo in repos - ] - if len(repos) == 1: - self.detected_repos_dropdown_ref.current.value = repos[0] - else: - self.detected_repos_dropdown_ref.current.value = f'{len(repos)} repo(s) found - select one' - 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.options = [] - - self.page.update() - - except Exception as e: - print(f"Error in _scan_repos_async: {e}") - - async def _scan_ollama_models_async(self): - """Scan Ollama server for available models""" - ollama_url = self.entries.get('OLLAMA_URL').value.strip() if 'OLLAMA_URL' in self.entries else '' - - if not ollama_url: - self._show_alert("Ollama URL Required", "Please enter the Ollama Server URL first.") - return - - if not ollama_url.startswith('http'): - ollama_url = f"http://{ollama_url}" - - def scan_models(): - try: - import requests - - ollama_api_key = self.entries.get('OLLAMA_API_KEY').value.strip() if 'OLLAMA_API_KEY' in self.entries else '' - - headers = {} - if ollama_api_key: - headers['Authorization'] = f'Bearer {ollama_api_key}' - - response = requests.get(f"{ollama_url}/api/tags", headers=headers, timeout=10) - response.raise_for_status() - - data = response.json() - models = data.get('models', []) - model_names = [model.get('name', '') for model in models if model.get('name')] - - # Cache the models - if self.cache_manager and model_names: - # Convert to cache format (list of dicts) - cache_data = [{'name': name} for name in model_names] - self.cache_manager.save_to_cache('ollama_models', ollama_url, cache_data) - print(f"Cached {len(model_names)} Ollama models") - - # Update UI - if self.ollama_model_dropdown_ref.current: - if model_names: - self.ollama_model_dropdown_ref.current.options = [ - ft.dropdown.Option(name) for name in model_names - ] - - # Restore saved selection if it exists in the list - saved_model = self.config.get('OLLAMA_MODEL', '') - if saved_model and saved_model in model_names: - self.ollama_model_dropdown_ref.current.value = saved_model - elif model_names: - # Otherwise select first model - self.ollama_model_dropdown_ref.current.value = model_names[0] - - self.page.update() - - models_text = "\n".join(f"• {name}" for name in model_names[:10]) - if len(model_names) > 10: - models_text += f"\n\n...and {len(model_names) - 10} more" - - else: - self._show_alert("No Models Found", "No models found on the Ollama server.\n\nUse 'ollama pull ' to download models.") - - except requests.exceptions.ConnectionError: - self._show_alert("Connection Error", f"Could not connect to Ollama server at:\n{ollama_url}\n\nMake sure Ollama is running and the URL is correct.") - except Exception as e: - self._show_alert("Scan Error", f"An error occurred while scanning for models:\n{str(e)}") - - await asyncio.to_thread(scan_models) - - def _test_connection(self, e): - """Test connection to configured services""" - config_values = self._get_config_values() - - results = [] - - # Test GitHub - if config_values.get('GITHUB_PAT'): - try: - from .github_api import GitHubAPI - api = GitHubAPI(config_values.get('GITHUB_PAT')) - results.append("GitHub: āœ… Token configured") - - if config_values.get('GITHUB_REPO'): - results.append(f"GitHub Repository: āœ… {config_values.get('GITHUB_REPO')}") - else: - results.append("GitHub Repository: āš ļø Not configured") - except Exception as e: - results.append(f"GitHub: āŒ Error - {str(e)}") - else: - results.append("GitHub: āŒ No token configured") - - # Test AI Provider - ai_provider = config_values.get('AI_PROVIDER', 'none').lower() - if ai_provider and ai_provider != 'none': - try: - from .ai_manager import AIManager - ai_manager = AIManager() - available, missing = ai_manager.check_ai_module_availability(ai_provider) - - if available: - results.append(f"AI Provider ({ai_provider}): āœ… Available") - else: - results.append(f"AI Provider ({ai_provider}): āš ļø Missing packages: {', '.join(missing)}") - except Exception as e: - results.append(f"AI Provider ({ai_provider}): āš ļø Error - {str(e)}") - else: - results.append("AI Provider: ā„¹ļø Disabled (using standard method)") - - # Show results - if results: - self._show_alert( - "Connection Test Results", - "\n".join(results) + "\n\nšŸ’” Full validation requires running the application." - ) - - def _clear_cache(self, e): - """Clear all cached items""" - def do_clear(): - if self.cache_manager: - self.cache_manager.invalidate_cache() - self._show_alert( - "Cache Cleared", - "All cached items have been cleared.\nFresh data will be loaded on next app start." - ) - else: - self._show_alert("Error", "Cache manager not available") - - # Show confirmation dialog - self._show_confirmation( - "Clear Cache", - "Are you sure you want to clear all cached items?\n\nAll cached data will be removed.\nThe next time you open the app, it will auto-load fresh data.", - on_confirm=do_clear - ) - - def _get_config_values(self) -> Dict[str, Any]: - """Get configuration values from entries""" - config_values = {} - - for key, widget in self.entries.items(): - if isinstance(widget, ft.Checkbox): - config_values[key] = 'true' if widget.value else 'false' - elif isinstance(widget, (ft.TextField, ft.Dropdown)): - value = widget.value or '' - if isinstance(value, str): - value = value.strip() - config_values[key] = value - - return config_values - - def _save_clicked(self, e): - """Handle save button click""" - try: - config_values = self._get_config_values() - - # Validate required fields - if not config_values.get('GITHUB_PAT'): - self._show_alert( - "Missing Configuration", - "GitHub Personal Access Token is required for basic functionality." - ) - return - - # Check AI provider setup - ai_provider = config_values.get('AI_PROVIDER', '').strip().lower() - if ai_provider and ai_provider not in ['none', '']: - if ai_provider in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot', 'ollama']: - available, missing = self._check_ai_packages(ai_provider) - if not available and missing: - # Offer to install missing packages - in_venv, env_name = self._detect_environment() - env_text = f"virtual environment '{env_name}'" if in_venv else "system-wide" - - def handle_install_and_save(e): - self.page.close(package_warning_dialog) - # Install packages and then save - self.page.run_task(lambda: self._install_and_save(missing, ai_provider, config_values)) - - def handle_save_anyway(e): - self.page.close(package_warning_dialog) - # Continue with save - self._do_save(config_values) - - def handle_cancel_save(e): - self.page.close(package_warning_dialog) - - package_warning_dialog = ft.AlertDialog( - modal=True, - title=ft.Text("Missing AI Packages"), - content=ft.Text( - f"AI provider '{ai_provider}' requires additional packages:\n\n" - f"{', '.join(missing)}\n\n" - f"Installation location: {env_text}\n\n" - f"Would you like to install them now?" - ), - actions=[ - ft.TextButton("Cancel", on_click=handle_cancel_save), - ft.TextButton("Save Without Installing", on_click=handle_save_anyway), - ft.FilledButton("Install & Save", on_click=handle_install_and_save), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - self.page.open(package_warning_dialog) - return # Don't save yet, wait for user choice - - # Save configuration (packages are already installed or not needed) - self._do_save(config_values) - - except Exception as e: - self._show_alert("Save Error", f"Error saving settings:\n{str(e)}") - - def _save_to_env_file(self, config_values: Dict[str, Any]) -> bool: - """Fallback method to save configuration to .env file""" - try: - env_content = "# GitHub Pulse Configuration\n" - env_content += "# Generated by Settings Dialog\n\n" - - for key, value in config_values.items(): - if value: - env_content += f"{key}={value}\n" - else: - env_content += f"{key}=\n" - - env_path = os.path.join(os.getcwd(), '.env') - with open(env_path, 'w', encoding='utf-8') as f: - f.write(env_content) - - return True - - except Exception as e: - print(f"Error saving to .env file: {e}") - return False - - def _cancel_clicked(self, e): - """Handle cancel button click""" - self.result = None - self._close_dialog() - - def _close_dialog(self): - """Close the dialog""" - # Use Flet 0.28+ API: page.close() instead of page.dialog - if self.dialog_ref.current: - self.page.close(self.dialog_ref.current) - - if self.on_result: - self.on_result(self.result) - - def _show_alert(self, title: str, message: str): - """Show an alert dialog""" - def close_dlg(e): - self.page.close(alert_dialog) - - alert_dialog = ft.AlertDialog( - modal=True, - title=ft.Text(title), - content=ft.Text(message), - actions=[ft.TextButton("OK", on_click=close_dlg)], - actions_alignment=ft.MainAxisAlignment.END, - ) - - self.page.open(alert_dialog) - - def _show_confirmation(self, title: str, message: str, on_confirm=None, on_cancel=None): - """Show a confirmation dialog""" - def handle_yes(e): - self.page.close(confirm_dialog) - if on_confirm: - on_confirm() - - def handle_no(e): - self.page.close(confirm_dialog) - if on_cancel: - on_cancel() - - confirm_dialog = ft.AlertDialog( - modal=True, - title=ft.Text(title), - content=ft.Text(message), - actions=[ - ft.TextButton("No", on_click=handle_no), - ft.FilledButton("Yes", on_click=handle_yes), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - self.page.open(confirm_dialog) diff --git a/src/app_components/settings_manager.py b/src/app_components/settings_manager.py deleted file mode 100644 index c75c12e..0000000 --- a/src/app_components/settings_manager.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Settings Manager -Handles application settings with live updates and secure storage. - -Non-secret settings are stored in config.json. -Secrets (API keys, tokens) are stored in the system keyring. -""" - -import json -import os -from pathlib import Path -from typing import Dict, Any, Optional, Callable -import keyring - - -class SettingsManager: - """ - Manages application settings with live updates. - - Features: - - Non-secret settings stored in JSON - - API keys stored securely in system keyring - - Live update notifications to registered listeners - - No app restart required for changes - """ - - # Keyring service name for this app - SERVICE_NAME = "GitHubPulse" - - # Keys that should be stored in keyring (secrets) - SECRET_KEYS = { - 'GITHUB_PAT', - 'ANTHROPIC_API_KEY', - 'OPENAI_API_KEY', - 'GITHUB_COPILOT_TOKEN', - 'CLAUDE_API_KEY', # Alternative name for Anthropic - 'GITHUB_TOKEN', # For GitHub Copilot - 'OLLAMA_API_KEY', # Optional Ollama API key - } - - # Default settings (non-secrets) - DEFAULT_SETTINGS = { - # GitHub Configuration - 'GITHUB_REPO': '', - 'FORKED_REPO': '', - 'LOCAL_REPO_PATH': '', - - # Application Settings - 'AI_PROVIDER': 'none', - 'DRY_RUN': 'false', - 'DEFAULT_BRANCH': 'main', - 'THEME_MODE': 'dark', - 'AUTO_REFRESH': 'true', - 'REFRESH_INTERVAL': '300', - - # Ollama Configuration - 'OLLAMA_URL': '', - 'OLLAMA_MODEL': '', - - # Custom AI Instructions - 'CUSTOM_INSTRUCTIONS': '', - } - - def __init__(self, config_dir: Optional[Path] = None): - """ - Initialize the settings manager. - - Args: - config_dir: Directory to store config.json. Defaults to app directory. - """ - # Determine config directory - if config_dir is None: - # Use app directory - config_dir = Path(__file__).parent.parent - self.config_dir = Path(config_dir) - self.config_file = self.config_dir / "config.json" - - # Settings storage - self._settings: Dict[str, Any] = {} - - # Registered change listeners - self._listeners: list[Callable[[str, Any], None]] = [] - - # Load settings - self.load() - - def load(self) -> Dict[str, Any]: - """ - Load settings from config.json and keyring. - - Returns: - Dictionary of all settings (secrets and non-secrets combined) - """ - # Start with defaults - self._settings = self.DEFAULT_SETTINGS.copy() - - # Load from JSON file - if self.config_file.exists(): - try: - with open(self.config_file, 'r') as f: - saved_settings = json.load(f) - # Only load non-secret settings from JSON - for key, value in saved_settings.items(): - if key not in self.SECRET_KEYS: - self._settings[key] = value - except Exception as e: - print(f"Error loading config.json: {e}") - - # Load secrets from keyring - for secret_key in self.SECRET_KEYS: - try: - value = keyring.get_password(self.SERVICE_NAME, secret_key) - if value: - self._settings[secret_key] = value - except Exception as e: - print(f"Error loading {secret_key} from keyring: {e}") - - return self._settings.copy() - - def save(self, settings: Optional[Dict[str, Any]] = None) -> bool: - """ - Save settings to config.json and keyring. - - Args: - settings: Settings to save. If None, saves current settings. - - Returns: - True if successful, False otherwise - """ - if settings is not None: - # Update internal settings - for key, value in settings.items(): - old_value = self._settings.get(key) - self._settings[key] = value - - # Notify listeners of changes - if old_value != value: - self._notify_change(key, value) - - try: - # Save non-secrets to JSON - json_settings = { - key: value for key, value in self._settings.items() - if key not in self.SECRET_KEYS - } - - with open(self.config_file, 'w') as f: - json.dump(json_settings, f, indent=2) - - # Save secrets to keyring - for secret_key in self.SECRET_KEYS: - if secret_key in self._settings: - value = self._settings[secret_key] - if value: # Only save non-empty values - try: - keyring.set_password(self.SERVICE_NAME, secret_key, str(value)) - except Exception as e: - print(f"Error saving {secret_key} to keyring: {e}") - - return True - - except Exception as e: - print(f"Error saving settings: {e}") - return False - - def get(self, key: str, default: Any = None) -> Any: - """ - Get a setting value. - - Args: - key: Setting key - default: Default value if key doesn't exist - - Returns: - Setting value or default - """ - return self._settings.get(key, default) - - def set(self, key: str, value: Any, save: bool = True) -> bool: - """ - Set a setting value with live update. - - Args: - key: Setting key - value: New value - save: Whether to persist immediately - - Returns: - True if successful - """ - old_value = self._settings.get(key) - self._settings[key] = value - - # Notify listeners - if old_value != value: - self._notify_change(key, value) - - # Save if requested - if save: - return self.save() - - return True - - def get_all(self) -> Dict[str, Any]: - """ - Get all settings. - - Returns: - Dictionary of all settings - """ - return self._settings.copy() - - def register_listener(self, callback: Callable[[str, Any], None]): - """ - Register a callback to be notified of setting changes. - - The callback will be called with (key, new_value) when a setting changes. - - Args: - callback: Function to call on settings change - """ - if callback not in self._listeners: - self._listeners.append(callback) - - def unregister_listener(self, callback: Callable[[str, Any], None]): - """ - Unregister a settings change callback. - - Args: - callback: Function to remove from listeners - """ - if callback in self._listeners: - self._listeners.remove(callback) - - def _notify_change(self, key: str, value: Any): - """ - Notify all registered listeners of a setting change. - - Args: - key: Setting key that changed - value: New value - """ - for listener in self._listeners: - try: - listener(key, value) - except Exception as e: - print(f"Error notifying listener of {key} change: {e}") - - def delete_secret(self, key: str) -> bool: - """ - Delete a secret from the keyring. - - Args: - key: Secret key to delete - - Returns: - True if successful - """ - if key not in self.SECRET_KEYS: - return False - - try: - keyring.delete_password(self.SERVICE_NAME, key) - if key in self._settings: - del self._settings[key] - self._notify_change(key, None) - return True - except Exception as e: - print(f"Error deleting {key} from keyring: {e}") - return False - - def migrate_from_env(self, env_file: Path) -> bool: - """ - Migrate settings from a .env file to the new system. - - Args: - env_file: Path to .env file - - Returns: - True if migration successful - """ - if not env_file.exists(): - print(f"Env file not found: {env_file}") - return False - - try: - # Read .env file - env_settings = {} - with open(env_file, 'r') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#'): - if '=' in line: - key, value = line.split('=', 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - env_settings[key] = value - - # Save to new system - self.save(env_settings) - - print(f"Successfully migrated {len(env_settings)} settings from .env") - return True - - except Exception as e: - print(f"Error migrating from .env: {e}") - return False diff --git a/src/app_components/utils.py b/src/app_components/utils.py deleted file mode 100644 index 63b8461..0000000 --- a/src/app_components/utils.py +++ /dev/null @@ -1,662 +0,0 @@ -""" -Utility functions and helpers -""" - -import json -import os -import re -import subprocess -import threading -import datetime -from pathlib import Path -from typing import Dict, Any, Optional, Tuple, List -from urllib.parse import urlparse - - -class Logger: - """Simple logger for GUI applications""" - - def __init__(self, text_widget=None): - self.text_widget = text_widget - self._lock = threading.Lock() - - def log(self, message: str) -> None: - """Log a message to the text widget and console""" - timestamp = __import__('datetime').datetime.now().strftime("%H:%M:%S") - formatted_message = f"[{timestamp}] {message}" - - try: - print(formatted_message) - except UnicodeEncodeError: - # Fallback: replace Unicode emojis with ASCII equivalents - safe_message = formatted_message.replace('āœ…', '[SUCCESS]').replace('āŒ', '[ERROR]').replace('āš ļø', '[WARNING]').replace('šŸ“‹', '[INFO]').replace('šŸ“„', '[FILE]').replace('šŸ“', '[LOCATION]').replace('šŸ“', '[EDIT]') - print(safe_message) - - if self.text_widget: - def update_widget(): - try: - with self._lock: - self.text_widget.config(state='normal') - self.text_widget.insert('end', formatted_message + '\n') - self.text_widget.see('end') - self.text_widget.config(state='disabled') - self.text_widget.update_idletasks() - except: - pass # Widget might be destroyed - - # Schedule update on main thread - if hasattr(self.text_widget, 'after'): - self.text_widget.after(0, update_widget) - else: - update_widget() - - -class PRNumberManager: - """Manages PR numbers for branch naming""" - - PR_COUNTER_FILE = '.pr_counter.json' - - @classmethod - def get_pr_counter_file(cls) -> str: - """Get the path to the PR counter file""" - script_dir = os.path.dirname(os.path.abspath(__file__)) - return os.path.join(script_dir, cls.PR_COUNTER_FILE) - - @classmethod - def load_pr_counter(cls) -> Dict[str, int]: - """Load the PR counter from file""" - counter_file = cls.get_pr_counter_file() - if os.path.exists(counter_file): - try: - with open(counter_file, 'r', encoding='utf-8') as f: - return json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - pass - return {} - - @classmethod - def save_pr_counter(cls, counter: Dict[str, int]) -> None: - """Save the PR counter to file""" - counter_file = cls.get_pr_counter_file() - try: - with open(counter_file, 'w', encoding='utf-8') as f: - json.dump(counter, f, indent=2) - except Exception as e: - print(f"Warning: Could not save PR counter: {e}") - - @classmethod - def get_next_pr_number(cls, provider_key: str) -> int: - """ - Get the next PR number for a given provider - - Args: - provider_key: Either the AI provider name ('chatgpt', 'claude') or 'gh_copilot' - - Returns: - Next available PR number for this provider - """ - try: - counter = cls.load_pr_counter() - current_number = counter.get(provider_key, 0) - next_number = current_number + 1 - counter[provider_key] = next_number - cls.save_pr_counter(counter) - return next_number - - except Exception as e: - print(f"Error managing PR counter: {e}") - # Fallback to a timestamp-based number - import time - return int(time.time()) % 10000 - - -class GitHubInfoExtractor: - """Extracts GitHub repository information from URLs""" - - @staticmethod - def extract_github_info(doc_url: str) -> Dict[str, Any]: - """Extract GitHub repository information from a document URL""" - try: - if not doc_url or 'github.com' not in doc_url: - return {'error': 'Not a GitHub URL'} - - parsed = urlparse(doc_url) - path_parts = parsed.path.strip('/').split('/') - - if len(path_parts) < 2: - return {'error': 'Invalid GitHub URL format'} - - owner = path_parts[0] - repo = path_parts[1] - - # Try to extract file path if it's a blob URL - file_path = None - if len(path_parts) > 3 and path_parts[2] == 'blob': - # Skip branch name and get file path - if len(path_parts) > 4: - file_path = '/'.join(path_parts[4:]) - - result = { - 'owner': owner, - 'repo': repo, - 'original_content_git_url': doc_url - } - - if file_path: - result['file_path'] = file_path - - # Try to find ms.author from the URL or repo name - ms_author = GitHubInfoExtractor._extract_ms_author(owner, repo, doc_url) - if ms_author: - result['ms_author'] = ms_author - - return result - - except Exception as e: - return {'error': f'Error parsing GitHub URL: {str(e)}'} - - @staticmethod - def _extract_ms_author(owner: str, repo: str, url: str) -> Optional[str]: - """Try to extract ms.author from various sources""" - try: - # Method 1: Check if owner looks like a Microsoft username - if owner.startswith('Microsoft') or 'microsoft' in owner.lower(): - # Try to extract from repo name or URL patterns - if '-' in repo: - parts = repo.split('-') - for part in parts: - if len(part) > 2 and part.islower(): - return part - - # Method 2: Look for patterns in the URL - url_lower = url.lower() - - # Common patterns for ms.author - patterns = [ - r'/([a-z][a-z0-9-]+[a-z0-9])/', # username-like patterns - r'author[=:]([a-z][a-z0-9-]+)', # author= or author: patterns - ] - - for pattern in patterns: - match = re.search(pattern, url_lower) - if match: - candidate = match.group(1) - # Validate it looks like a reasonable username - if 3 <= len(candidate) <= 20 and candidate.replace('-', '').isalnum(): - return candidate - - return None - - except Exception: - return None - - -class WorkItemFieldExtractor: - """Extracts and processes item fields (placeholder for future implementation)""" - - @staticmethod - def extract_work_item_fields(work_item: Dict[str, Any]) -> Dict[str, Any]: - """Extract and process fields from work item (placeholder)""" - fields = work_item.get('fields', {}) - - # Extract basic fields - item_id = work_item.get('id', 'Unknown') - title = fields.get('System.Title', 'No Title') - - # Extract custom fields with fallbacks - nature_of_request = ( - fields.get('Custom.Natureofrequest') or - fields.get('Custom.NatureOfRequest') or - fields.get('Microsoft.VSTS.Common.DescriptionHtml', '') - ) - - # Clean HTML if present - if nature_of_request and '<' in nature_of_request: - nature_of_request = WorkItemFieldExtractor._clean_html(nature_of_request) - - mydoc_url = ( - fields.get('Custom.MyDocURL') or - fields.get('Custom.DocumentURL') or - fields.get('Custom.URL', '') - ) - - text_to_change = ( - fields.get('Custom.TextToChange') or - fields.get('Custom.CurrentText', '') - ) - - new_text = ( - fields.get('Custom.NewText') or - fields.get('Custom.ProposedText') or - fields.get('Custom.ReplacementText', '') - ) - - # Extract GitHub info from the document URL - github_info = GitHubInfoExtractor.extract_github_info(mydoc_url) - - return { - 'id': item_id, - 'title': title, - 'nature_of_request': nature_of_request, - 'mydoc_url': mydoc_url, - 'text_to_change': text_to_change, - 'new_text': new_text, - 'github_info': github_info, - 'status': 'Ready', - 'source': 'Generic' - } - - @staticmethod - def extract_uuf_item_fields(uuf_item: Dict[str, Any]) -> Dict[str, Any]: - """Extract and process fields from custom item (placeholder)""" - # UUF items have different field structure - item_id = uuf_item.get('cr_uufitemid', 'Unknown') - title = uuf_item.get('cr_title', 'No Title') - - nature_of_request = uuf_item.get('cr_description', '') - mydoc_url = uuf_item.get('cr_documenturl', '') - text_to_change = uuf_item.get('cr_currenttext', '') - new_text = uuf_item.get('cr_newtext', '') - - # Extract GitHub info - github_info = GitHubInfoExtractor.extract_github_info(mydoc_url) - - return { - 'id': item_id, - 'title': title, - 'nature_of_request': nature_of_request, - 'mydoc_url': mydoc_url, - 'text_to_change': text_to_change, - 'new_text': new_text, - 'github_info': github_info, - 'status': 'Ready', - 'source': 'UUF' - } - - @staticmethod - def _clean_html(html_text: str) -> str: - """Remove HTML tags and decode entities""" - import html - - # Remove HTML tags - clean_text = re.sub(r'<[^>]+>', '', html_text) - - # Decode HTML entities - clean_text = html.unescape(clean_text) - - # Clean up whitespace - clean_text = re.sub(r'\s+', ' ', clean_text).strip() - - return clean_text - - -class ContentBuilders: - """Builds content for GitHub issues and PRs""" - - @staticmethod - def build_issue_title(item: Dict[str, Any]) -> str: - """Build GitHub issue title""" - item_id = item.get('id', '') - if item_id: - return f"[#{item_id}] {item['title']}" - return f"{item['title']}" - - @staticmethod - def build_issue_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str: - """Build GitHub issue body""" - body_parts = [] - - # Header - body_parts.append("## Item Details") - body_parts.append("") - - # Make ID a hyperlink if source URL is available - if item.get('source_url'): - body_parts.append(f"**ID:** [{item['id']}]({item['source_url']})") - else: - body_parts.append(f"**ID:** {item['id']}") - - body_parts.append(f"**Title:** {item['title']}") - body_parts.append("") - - # Nature of request - if item['nature_of_request']: - body_parts.append("**Nature of Request:**") - body_parts.append(item['nature_of_request']) - body_parts.append("") - - # Document information - if item['mydoc_url']: - body_parts.append("**Document URL:**") - body_parts.append(item['mydoc_url']) - body_parts.append("") - - # Change details - body_parts.append("## Change Details") - body_parts.append("") - - if item['text_to_change']: - body_parts.append("**Text to Change:**") - body_parts.append("```") - body_parts.append(item['text_to_change']) - body_parts.append("```") - body_parts.append("") - - if item['new_text']: - body_parts.append("**Proposed New Text:**") - body_parts.append("```") - body_parts.append(item['new_text']) - body_parts.append("```") - body_parts.append("") - - # Repository info - if github_info.get('owner') and github_info.get('repo'): - body_parts.append("## Repository Information") - body_parts.append("") - body_parts.append(f"**Repository:** {github_info['owner']}/{github_info['repo']}") - - if github_info.get('ms_author'): - body_parts.append(f"**Author:** @{github_info['ms_author']}") - - body_parts.append("") - - # Instructions for manual review - body_parts.append("## Instructions") - body_parts.append("") - body_parts.append("This issue requires manual review of the proposed documentation change.") - body_parts.append("") - body_parts.append("**Next Steps:**") - body_parts.append("1. Review the proposed change above") - body_parts.append("2. Navigate to the document URL") - body_parts.append("3. Locate the text that needs to be changed") - body_parts.append("4. Make the appropriate updates") - body_parts.append("5. Close this issue when complete") - body_parts.append("") - body_parts.append("---") - body_parts.append("*Created automatically by GitHub Pulse*") - - return "\n".join(body_parts) - - @staticmethod - def build_pr_title(item: Dict[str, Any]) -> str: - """Build GitHub PR title""" - item_id = item.get('id', '') - if item_id: - return f"[#{item_id}] {item['title']}" - return f"{item['title']}" - - @staticmethod - def build_pr_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str: - """Build GitHub PR body""" - body_parts = [] - - # Header - body_parts.append("## Documentation Update") - body_parts.append("") - - # Make ID a hyperlink if source URL is available - if item.get('source_url'): - body_parts.append(f"**ID:** [{item['id']}]({item['source_url']})") - else: - body_parts.append(f"**ID:** {item['id']}") - - body_parts.append(f"**Title:** {item['title']}") - body_parts.append("") - - # Nature of request - if item['nature_of_request']: - body_parts.append("**Description:**") - body_parts.append(item['nature_of_request']) - body_parts.append("") - - # Change summary - body_parts.append("## Changes Made") - body_parts.append("") - body_parts.append("This PR updates documentation as requested.") - body_parts.append("") - - if item['text_to_change'] and item['new_text']: - body_parts.append("**Change Summary:**") - body_parts.append("- Updated specific text content as requested") - body_parts.append("") - - body_parts.append("
") - body_parts.append("View Change Details") - body_parts.append("") - body_parts.append("**Original Text:**") - body_parts.append("```") - body_parts.append(item['text_to_change']) - body_parts.append("```") - body_parts.append("") - body_parts.append("**New Text:**") - body_parts.append("```") - body_parts.append(item['new_text']) - body_parts.append("```") - body_parts.append("
") - body_parts.append("") - - # Repository info - if github_info.get('ms_author'): - body_parts.append(f"**Author:** @{github_info['ms_author']}") - body_parts.append("") - - # Review instructions - body_parts.append("## Review Checklist") - body_parts.append("") - body_parts.append("- [ ] Changes match the requested update") - body_parts.append("- [ ] No unintended changes were made") - body_parts.append("- [ ] Grammar and formatting are correct") - body_parts.append("- [ ] Links and references are working") - body_parts.append("") - - body_parts.append("---") - body_parts.append("*Created automatically by GitHub Pulse*") - - return "\n".join(body_parts) - - -class LocalRepositoryScanner: - """Scans local repository path for Git repositories""" - - @staticmethod - def scan_local_repos(local_repo_path: str) -> List[str]: - """Scan local path for Git repositories""" - if not local_repo_path or not os.path.exists(local_repo_path): - return [] - - repos = [] - try: - for item in os.listdir(local_repo_path): - item_path = os.path.join(local_repo_path, item) - if os.path.isdir(item_path): - git_path = os.path.join(item_path, '.git') - if os.path.exists(git_path): - # Get remote origin URL to determine repo name - repo_info = LocalRepositoryScanner.get_repo_info(item_path) - if repo_info: - repos.append(repo_info) - else: - # Fallback to folder name - repos.append(f"local/{item}") - except PermissionError: - pass # Skip directories we can't access - except Exception as e: - print(f"Error scanning local repos: {e}") - - return sorted(repos) - - @staticmethod - def get_repo_info(repo_path: str) -> Optional[str]: - """Get repository information from local Git repo""" - try: - # Get remote origin URL - result = subprocess.run( - ['git', 'config', '--get', 'remote.origin.url'], - cwd=repo_path, - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode == 0: - url = result.stdout.strip() - return LocalRepositoryScanner.parse_git_url(url) - except Exception: - pass - - return None - - @staticmethod - def parse_git_url(url: str) -> Optional[str]: - """Parse Git URL to extract owner/repo format""" - try: - # Handle GitHub URLs - if 'github.com' in url: - # Handle both HTTPS and SSH URLs - if url.startswith('git@'): - # SSH: git@github.com:owner/repo.git - parts = url.split(':')[-1].replace('.git', '') - return parts - else: - # HTTPS: https://github.com/owner/repo.git - parsed = urlparse(url) - path = parsed.path.strip('/').replace('.git', '') - return path - except: - pass - - return None - - @staticmethod - def clone_repository(repo_url: str, local_path: str, repo_name: str) -> bool: - """Clone a repository to local path""" - try: - target_path = os.path.join(local_path, repo_name.split('/')[-1]) - - if os.path.exists(target_path): - print(f"Repository already exists at {target_path}") - return True - - os.makedirs(local_path, exist_ok=True) - - result = subprocess.run( - ['git', 'clone', repo_url, target_path], - capture_output=True, - text=True, - timeout=300 # 5 minutes timeout - ) - - if result.returncode == 0: - print(f"Successfully cloned {repo_url} to {target_path}") - return True - else: - print(f"Failed to clone repository: {result.stderr}") - return False - - except Exception as e: - print(f"Error cloning repository: {e}") - return False - - -class ConfigurationHelpers: - """Configuration and validation utilities""" - - @staticmethod - def validate_ai_provider_setup(config: Dict[str, Any], parent_window=None) -> bool: - """Validate AI provider setup and offer to install missing modules - - Args: - config: Configuration dictionary - parent_window: Parent tkinter window for dialogs - - Returns: - bool: True if setup is valid or user handled the issue - """ - ai_provider = config.get('AI_PROVIDER', '').lower() - - if not ai_provider or ai_provider == 'none': - return True # No AI provider selected, nothing to validate - - try: - # Try to import AI manager for validation - from .ai_manager import AIManager - ai_manager = AIManager() - - # Check if modules are available - available, missing = ai_manager.check_ai_module_availability(ai_provider) - - if available: - return True # All modules available - - print(f"āš ļø AI Provider '{ai_provider}' selected but missing required packages: {', '.join(missing)}") - - # Offer to install missing packages - success = ai_manager.install_ai_packages(missing, parent_window) - - if success: - # Re-check availability after installation - available, still_missing = ai_manager.check_ai_module_availability(ai_provider) - if available: - print(f"āœ… AI Provider '{ai_provider}' is now ready to use") - return True - else: - print(f"āš ļø Some packages may still be missing: {', '.join(still_missing)}") - print("Please restart the application after installation completes") - return False - - return False - - except ImportError: - # AI manager not available, skip validation - return True - - @staticmethod - def create_default_env_file() -> bool: - """Create a default .env file with all settings blank""" - try: - default_config = """# GitHub Pulse Configuration -# Generated automatically - fill in your values -# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore. - -# GitHub Configuration -GITHUB_PAT= -GITHUB_REPO= -FORKED_REPO= - -# Application Settings -DRY_RUN=false - -# AI Provider Configuration (for local PR creation with AI assistance) -AI_PROVIDER= -CLAUDE_API_KEY= -OPENAI_API_KEY= -GITHUB_TOKEN= -LOCAL_REPO_PATH= - -# Custom AI Instructions (optional) -CUSTOM_INSTRUCTIONS= -""" - with open('.env', 'w', encoding='utf-8') as f: - f.write(default_config) - - print("Created default .env file with blank values") - return True - - except Exception as e: - print(f"Error creating default .env file: {e}") - return False - -# Compatibility functions for direct function access -def get_next_pr_number(provider_key: str) -> int: - """Compatibility function for direct access to PR number generation""" - return PRNumberManager.get_next_pr_number(provider_key) - - -def validate_ai_provider_setup(config: Dict[str, Any], parent_window=None) -> bool: - """Compatibility function for direct access to AI provider validation""" - return ConfigurationHelpers.validate_ai_provider_setup(config, parent_window) - - -def create_default_env_file() -> bool: - """Compatibility function for direct access to .env file creation""" - return ConfigurationHelpers.create_default_env_file() \ No newline at end of file diff --git a/src/app_components/workflow.py b/src/app_components/workflow.py deleted file mode 100644 index 56eee04..0000000 --- a/src/app_components/workflow.py +++ /dev/null @@ -1,653 +0,0 @@ -""" -Workflow Manager -Manages GitHub workflow items (Issues and Pull Requests) from target and fork repositories -""" - -import requests -from typing import List, Dict, Any, Optional, Tuple - - -class WorkflowItem: - """Represents a GitHub workflow item (Issue or PR)""" - - def __init__(self, item_type: str, data: Dict[str, Any], repo_source: str): - """ - Initialize a workflow item - - Args: - item_type: 'issue' or 'pull_request' - data: Raw data from GitHub API - repo_source: 'target' or 'fork' - """ - self.item_type = item_type - self.repo_source = repo_source - self.data = data - - # Extract common fields - self.number = data.get('number') - self.title = data.get('title', 'No Title') - self.state = data.get('state', 'unknown') - self.created_at = data.get('created_at', '') - self.updated_at = data.get('updated_at', '') - self.body = data.get('body', '') - self.url = data.get('html_url', '') - self.api_url = data.get('url', '') - - # Author information - user = data.get('user', {}) - self.author = user.get('login', 'unknown') if user else 'unknown' - self.author_url = user.get('html_url', '') if user else '' - - # Labels - self.labels = [label.get('name', '') for label in data.get('labels', [])] - - # Assignees - assignees = data.get('assignees', []) - self.assignees = [a.get('login', '') for a in assignees if a] - - # PR-specific fields - if item_type == 'pull_request': - self.is_draft = data.get('draft', False) - self.mergeable_state = data.get('mergeable_state', 'unknown') - self.merged = data.get('merged', False) - self.base_ref = data.get('base', {}).get('ref', '') - self.head_ref = data.get('head', {}).get('ref', '') - else: - self.is_draft = False - self.mergeable_state = None - self.merged = False - self.base_ref = None - self.head_ref = None - - # Comments count - self.comments_count = data.get('comments', 0) - - def __repr__(self): - return f"" - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for easy serialization""" - return { - 'item_type': self.item_type, - 'repo_source': self.repo_source, - 'data': self.data, # Include raw data for full reconstruction - 'number': self.number, - 'title': self.title, - 'state': self.state, - 'created_at': self.created_at, - 'updated_at': self.updated_at, - 'body': self.body, - 'url': self.url, - 'api_url': self.api_url, - 'author': self.author, - 'author_url': self.author_url, - 'labels': self.labels, - 'assignees': self.assignees, - 'is_draft': self.is_draft, - 'mergeable_state': self.mergeable_state, - 'merged': self.merged, - 'base_ref': self.base_ref, - 'head_ref': self.head_ref, - 'comments_count': self.comments_count - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'WorkflowItem': - """Create WorkflowItem from dictionary (for cache deserialization)""" - # Extract the raw GitHub API data if available, otherwise use the dict itself - raw_data = data.get('data', data) - item_type = data.get('item_type', 'issue') - repo_source = data.get('repo_source', 'target') - - return cls(item_type, raw_data, repo_source) - - -class GitHubRepoFetcher: - """Fetches repository information from GitHub""" - - def __init__(self, github_token: str, logger=None): - """ - Initialize the repo fetcher - - Args: - github_token: GitHub Personal Access Token - logger: Optional logger instance - """ - self.token = github_token - self.logger = logger - self.headers = { - "Authorization": f"Bearer {github_token}", - "Accept": "application/vnd.github+json", - "User-Agent": "github-automation-tool/1.0" - } - - def log(self, message: str): - """Log a message""" - if self.logger: - self.logger.log(message) - else: - print(message) - - def get_authenticated_user(self) -> Optional[Dict[str, Any]]: - """ - Get information about the authenticated user - - Returns: - Dictionary with user information or None if error - """ - try: - url = "https://api.github.com/user" - response = requests.get(url, headers=self.headers, timeout=30) - response.raise_for_status() - return response.json() - except Exception as e: - self.log(f"āŒ Error fetching authenticated user: {str(e)}") - return None - - def fetch_user_repos(self, repo_type: str = 'owner', per_page: int = 100) -> List[Dict[str, Any]]: - """ - Fetch repositories for the authenticated user - - Args: - repo_type: 'owner', 'member', or 'all' - per_page: Number of repos per page (max 100) - - Returns: - List of repository dictionaries - """ - try: - url = "https://api.github.com/user/repos" - params = { - 'type': repo_type, - 'per_page': min(per_page, 100), - 'sort': 'updated', - 'direction': 'desc' - } - - response = requests.get(url, headers=self.headers, params=params, timeout=30) - response.raise_for_status() - - repos = response.json() - self.log(f"āœ… Found {len(repos)} repositories ({repo_type})") - return repos - - except Exception as e: - self.log(f"āŒ Error fetching user repos: {str(e)}") - return [] - - def fetch_repos_with_permissions(self, min_permission: str = 'push') -> List[Dict[str, Any]]: - """ - Fetch repositories where user has specific permissions - - Args: - min_permission: Minimum permission level ('pull', 'push', 'admin') - - Returns: - List of repository dictionaries with sufficient permissions - """ - try: - # Fetch all repos user has access to - all_repos = self.fetch_user_repos(repo_type='all') - - # Filter by permission level - filtered_repos = [] - permission_levels = {'pull': 0, 'push': 1, 'admin': 2} - min_level = permission_levels.get(min_permission, 1) - - for repo in all_repos: - permissions = repo.get('permissions', {}) - - # Check permission level - if permissions.get('admin'): - level = 2 - elif permissions.get('push'): - level = 1 - elif permissions.get('pull'): - level = 0 - else: - level = -1 - - if level >= min_level: - filtered_repos.append(repo) - - self.log(f"āœ… Found {len(filtered_repos)} repos with '{min_permission}' permission or higher") - return filtered_repos - - except Exception as e: - self.log(f"āŒ Error fetching repos with permissions: {str(e)}") - return [] - - def search_repositories(self, query: str, per_page: int = 30) -> List[Dict[str, Any]]: - """ - Search for repositories on GitHub - - Args: - query: Search query string - per_page: Number of results per page (max 100) - - Returns: - List of repository dictionaries - """ - if not query or not query.strip(): - return [] - - try: - url = "https://api.github.com/search/repositories" - params = { - 'q': query.strip(), - 'per_page': min(per_page, 100), - 'sort': 'updated', - 'order': 'desc' - } - - response = requests.get(url, headers=self.headers, params=params, timeout=30) - response.raise_for_status() - - data = response.json() - repos = data.get('items', []) - total_count = data.get('total_count', 0) - - self.log(f"āœ… Search found {total_count} repositories (showing {len(repos)})") - return repos - - except Exception as e: - self.log(f"āŒ Error searching repositories: {str(e)}") - return [] - - def get_repo_names(self, repos: List[Dict[str, Any]]) -> List[str]: - """ - Extract repository names in 'owner/repo' format - - Args: - repos: List of repository dictionaries - - Returns: - List of repository name strings - """ - return [repo.get('full_name', '') for repo in repos if repo.get('full_name')] - - -class WorkflowManager: - """Manages workflow items from GitHub repositories""" - - def __init__(self, github_token: str, logger=None): - """ - Initialize the workflow manager - - Args: - github_token: GitHub Personal Access Token - logger: Optional logger instance - """ - self.token = github_token - self.logger = logger - self.headers = { - "Authorization": f"Bearer {github_token}", - "Accept": "application/vnd.github+json", - "User-Agent": "github-automation-tool/1.0" - } - # Initialize repo fetcher - self.repo_fetcher = GitHubRepoFetcher(github_token, logger) - - def log(self, message: str): - """Log a message""" - if self.logger: - self.logger.log(message) - else: - print(message) - - def _parse_repo(self, repo_str: str) -> Optional[Tuple[str, str]]: - """ - Parse a repository string into owner and name - - Args: - repo_str: Repository string in format "owner/repo" - - Returns: - Tuple of (owner, repo) or None if invalid - """ - if not repo_str or '/' not in repo_str: - return None - - parts = repo_str.strip().split('/') - if len(parts) != 2: - return None - - return parts[0], parts[1] - - def fetch_issues(self, repo_str: str, repo_source: str = 'target', - state: str = 'all', per_page: int = 100) -> List[WorkflowItem]: - """ - Fetch issues from a repository - - Args: - repo_str: Repository string in format "owner/repo" - repo_source: 'target' or 'fork' to identify source - state: 'open', 'closed', or 'all' - per_page: Number of items per page (max 100) - - Returns: - List of WorkflowItem objects - """ - parsed = self._parse_repo(repo_str) - if not parsed: - self.log(f"L Invalid repository format: {repo_str}") - return [] - - owner, repo = parsed - self.log(f"Fetching issues from {owner}/{repo} ({repo_source})...") - - try: - url = f"https://api.github.com/repos/{owner}/{repo}/issues" - params = { - 'state': state, - 'per_page': min(per_page, 100), - 'sort': 'updated', - 'direction': 'desc' - } - - response = requests.get(url, headers=self.headers, params=params, timeout=30) - response.raise_for_status() - - items_data = response.json() - - # Filter out pull requests (GitHub's issues endpoint includes PRs) - issues_data = [item for item in items_data if 'pull_request' not in item] - - issues = [WorkflowItem('issue', data, repo_source) for data in issues_data] - - self.log(f" Found {len(issues)} issues in {owner}/{repo}") - return issues - - except requests.HTTPError as e: - self.log(f"L HTTP Error fetching issues from {owner}/{repo}: {e}") - if e.response.status_code == 401: - self.log(" Check your GitHub Personal Access Token") - elif e.response.status_code == 404: - self.log(" Repository not found or no access") - return [] - except Exception as e: - self.log(f"L Error fetching issues from {owner}/{repo}: {str(e)}") - return [] - - def fetch_pull_requests(self, repo_str: str, repo_source: str = 'target', - state: str = 'all', per_page: int = 100) -> List[WorkflowItem]: - """ - Fetch pull requests from a repository - - Args: - repo_str: Repository string in format "owner/repo" - repo_source: 'target' or 'fork' to identify source - state: 'open', 'closed', or 'all' - per_page: Number of items per page (max 100) - - Returns: - List of WorkflowItem objects - """ - parsed = self._parse_repo(repo_str) - if not parsed: - self.log(f"L Invalid repository format: {repo_str}") - return [] - - owner, repo = parsed - self.log(f"Fetching pull requests from {owner}/{repo} ({repo_source})...") - - try: - url = f"https://api.github.com/repos/{owner}/{repo}/pulls" - params = { - 'state': state, - 'per_page': min(per_page, 100), - 'sort': 'updated', - 'direction': 'desc' - } - - response = requests.get(url, headers=self.headers, params=params, timeout=30) - response.raise_for_status() - - prs_data = response.json() - prs = [WorkflowItem('pull_request', data, repo_source) for data in prs_data] - - self.log(f" Found {len(prs)} pull requests in {owner}/{repo}") - return prs - - except requests.HTTPError as e: - self.log(f"L HTTP Error fetching PRs from {owner}/{repo}: {e}") - if e.response.status_code == 401: - self.log(" Check your GitHub Personal Access Token") - elif e.response.status_code == 404: - self.log(" Repository not found or no access") - return [] - except Exception as e: - self.log(f"L Error fetching PRs from {owner}/{repo}: {str(e)}") - return [] - - def fetch_all_workflow_items(self, target_repo: str, fork_repo: str = None, - include_issues: bool = True, - include_prs: bool = True, - state: str = 'all') -> Dict[str, List[WorkflowItem]]: - """ - Fetch all workflow items from both target and fork repositories - - Args: - target_repo: Target repository string "owner/repo" - fork_repo: Fork repository string "owner/repo" (optional) - include_issues: Whether to fetch issues - include_prs: Whether to fetch pull requests - state: 'open', 'closed', or 'all' - - Returns: - Dictionary with keys 'target_issues', 'target_prs', 'fork_issues', 'fork_prs' - """ - results = { - 'target_issues': [], - 'target_prs': [], - 'fork_issues': [], - 'fork_prs': [] - } - - # Fetch from target repository - if target_repo: - if include_issues: - results['target_issues'] = self.fetch_issues(target_repo, 'target', state) - if include_prs: - results['target_prs'] = self.fetch_pull_requests(target_repo, 'target', state) - - # Fetch from fork repository - if fork_repo: - if include_issues: - results['fork_issues'] = self.fetch_issues(fork_repo, 'fork', state) - if include_prs: - results['fork_prs'] = self.fetch_pull_requests(fork_repo, 'fork', state) - - # Log summary - total = sum(len(items) for items in results.values()) - self.log(f"\n=ļæ½ Summary: Fetched {total} total items") - self.log(f" Target Issues: {len(results['target_issues'])}") - self.log(f" Target PRs: {len(results['target_prs'])}") - if fork_repo: - self.log(f" Fork Issues: {len(results['fork_issues'])}") - self.log(f" Fork PRs: {len(results['fork_prs'])}") - - return results - - def get_combined_items(self, workflow_items: Dict[str, List[WorkflowItem]], - sort_by: str = 'updated') -> List[WorkflowItem]: - """ - Combine and sort all workflow items - - Args: - workflow_items: Dictionary from fetch_all_workflow_items() - sort_by: 'updated', 'created', or 'number' - - Returns: - Sorted list of all workflow items - """ - all_items = [] - for items_list in workflow_items.values(): - all_items.extend(items_list) - - # Sort items - if sort_by == 'updated': - all_items.sort(key=lambda x: x.updated_at, reverse=True) - elif sort_by == 'created': - all_items.sort(key=lambda x: x.created_at, reverse=True) - elif sort_by == 'number': - all_items.sort(key=lambda x: x.number, reverse=True) - - return all_items - - def filter_items(self, items: List[WorkflowItem], **filters) -> List[WorkflowItem]: - """ - Filter workflow items based on criteria - - Args: - items: List of WorkflowItem objects - **filters: Filter criteria (state, item_type, repo_source, author, labels) - - Returns: - Filtered list of items - """ - filtered = items - - if 'state' in filters and filters['state']: - filtered = [item for item in filtered if item.state == filters['state']] - - if 'item_type' in filters and filters['item_type']: - filtered = [item for item in filtered if item.item_type == filters['item_type']] - - if 'repo_source' in filters and filters['repo_source']: - filtered = [item for item in filtered if item.repo_source == filters['repo_source']] - - if 'author' in filters and filters['author']: - filtered = [item for item in filtered if item.author == filters['author']] - - if 'labels' in filters and filters['labels']: - label_filter = filters['labels'] - if isinstance(label_filter, str): - label_filter = [label_filter] - filtered = [item for item in filtered - if any(label in item.labels for label in label_filter)] - - return filtered - - def fetch_comments(self, repo_str: str, issue_number: int, is_pull_request: bool = False) -> List[Dict[str, Any]]: - """ - Fetch comments for an issue or pull request - - Args: - repo_str: Repository string in format "owner/repo" - issue_number: Issue or PR number - is_pull_request: Whether this is a pull request (for PR-specific comments) - - Returns: - List of comment dictionaries with keys: 'user', 'body', 'created_at', 'updated_at' - """ - try: - # Parse repository string - if '/' not in repo_str: - self.log(f"Invalid repository format: {repo_str}") - return [] - - owner, repo = repo_str.split('/', 1) - - # Fetch issue/PR comments (these are the same endpoint for both issues and PRs) - url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments" - print(f"DEBUG: Fetching comments from URL: {url}", flush=True) - - response = requests.get(url, headers=self.headers) - print(f"DEBUG: Response status code: {response.status_code}", flush=True) - print(f"DEBUG: Response headers: {dict(response.headers)}", flush=True) - print(f"DEBUG: Response text length: {len(response.text)}", flush=True) - print(f"DEBUG: Response content (first 500): {response.text[:500]}", flush=True) - - response.raise_for_status() - - response_data = response.json() - print(f"DEBUG: Response data type: {type(response_data)}", flush=True) - print(f"DEBUG: Number of items: {len(response_data) if isinstance(response_data, list) else 'Not a list'}", flush=True) - - if isinstance(response_data, list) and len(response_data) > 0: - print(f"DEBUG: First item keys: {list(response_data[0].keys())}", flush=True) - - comments = [] - for comment_data in response_data: - comments.append({ - 'user': comment_data.get('user', {}).get('login', 'unknown'), - 'body': comment_data.get('body', ''), - 'created_at': comment_data.get('created_at', ''), - 'updated_at': comment_data.get('updated_at', ''), - 'url': comment_data.get('html_url', '') - }) - - self.log(f"Fetched {len(comments)} comments for {repo_str} #{issue_number}") - print(f"DEBUG: Successfully parsed {len(comments)} comments", flush=True) - return comments - - except requests.exceptions.RequestException as e: - self.log(f"Error fetching comments for {repo_str} #{issue_number}: {e}") - print(f"DEBUG: RequestException occurred: {e}", flush=True) - import traceback - traceback.print_exc() - return [] - except Exception as e: - self.log(f"Unexpected error fetching comments: {e}") - print(f"DEBUG: Exception occurred: {e}", flush=True) - import traceback - traceback.print_exc() - return [] - - def fetch_pr_files(self, repo_str: str, pr_number: int) -> List[Dict[str, Any]]: - """ - Fetch the list of files changed in a pull request - - Args: - repo_str: Repository string in format "owner/repo" - pr_number: Pull request number - - Returns: - List of file dictionaries with keys: 'filename', 'status', 'additions', 'deletions', 'changes', 'patch' - """ - try: - # Parse repository string - if '/' not in repo_str: - self.log(f"Invalid repository format: {repo_str}") - return [] - - owner, repo = repo_str.split('/', 1) - - # Fetch PR files - url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files" - print(f"DEBUG: Fetching PR files from URL: {url}", flush=True) - - response = requests.get(url, headers=self.headers) - response.raise_for_status() - - files_data = response.json() - print(f"DEBUG: Found {len(files_data)} files in PR #{pr_number}", flush=True) - - files = [] - for file_data in files_data: - files.append({ - 'filename': file_data.get('filename', ''), - 'status': file_data.get('status', ''), # added, removed, modified, renamed - 'additions': file_data.get('additions', 0), - 'deletions': file_data.get('deletions', 0), - 'changes': file_data.get('changes', 0), - 'patch': file_data.get('patch', ''), # The actual diff patch - 'blob_url': file_data.get('blob_url', ''), - }) - - self.log(f"Fetched {len(files)} files for PR {repo_str} #{pr_number}") - return files - - except requests.exceptions.RequestException as e: - self.log(f"Error fetching PR files for {repo_str} #{pr_number}: {e}") - print(f"DEBUG: RequestException occurred: {e}", flush=True) - import traceback - traceback.print_exc() - return [] - except Exception as e: - self.log(f"Unexpected error fetching PR files: {e}") - print(f"DEBUG: Exception occurred: {e}", flush=True) - import traceback - traceback.print_exc() - return [] diff --git a/src/assets/icon.png b/src/assets/icon.png deleted file mode 100644 index 6159727..0000000 Binary files a/src/assets/icon.png and /dev/null differ diff --git a/src/assets/splash_android.png b/src/assets/splash_android.png deleted file mode 100644 index 6159727..0000000 Binary files a/src/assets/splash_android.png and /dev/null differ diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 056f2ed..0000000 --- a/src/main.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -GitHub Pulse -Main application entry point - -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 os -import flet as ft - -# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors) -ft.icons = ft.Icons -ft.colors = ft.Colors - -# Import our modular components -try: - from app_components.config_manager import ConfigManager - from app_components.ai_manager import AIManager - from app_components.github_api import GitHubAPI - from app_components.main_gui import MainGUI -except ImportError as e: - print(f"Error importing application components: {e}") - print("Make sure all files are present in the app_components folder") - # In production builds, show a user-friendly error - if getattr(sys, 'frozen', False): - import traceback - error_details = traceback.format_exc() - print(error_details) - sys.exit(1) - - -class GitHubAutomationApp: - """Main application class that orchestrates all components""" - - def __init__(self, page: ft.Page): - """Initialize the application""" - self.page = page - - # Configure page - self.page.title = "GitHub Pulse" - self.page.theme_mode = ft.ThemeMode.DARK - self.page.padding = 0 - - # Set window size with platform detection - # Mobile devices will use full screen - is_mobile = page.web or (hasattr(page, 'platform') and - page.platform in ['android', 'ios']) - - if not is_mobile: - self.page.window_width = 1400 - self.page.window_height = 1000 - self.page.window_min_width = 1200 - self.page.window_min_height = 800 - - # Material Design 3 theme with optimized settings - self.page.theme = ft.Theme( - color_scheme_seed="blue", - use_material3=True, - ) - - # Initialize core managers - self.config_manager = ConfigManager() - self.ai_manager = AIManager() - - # Load configuration - self.config = self.config_manager.load_configuration() - - # Initialize dry run state - dry_run_config = self.config.get('DRY_RUN', 'false') - self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on') - - # Register listener for live settings updates - self.config_manager.register_listener(self._on_setting_changed) - - # Initialize main GUI - self.main_gui = MainGUI( - page=self.page, - config_manager=self.config_manager, - ai_manager=self.ai_manager, - app=self - ) - - # Build UI - self.page.add(self.main_gui.build()) - - # Check AI provider setup after a short delay - self.page.run_task(self._check_ai_provider_setup_async) - - async def _check_ai_provider_setup_async(self): - """Check and setup AI providers after GUI initialization""" - try: - # Wait a bit for GUI to fully load - import asyncio - await asyncio.sleep(0.5) - - ai_provider = self.config.get('AI_PROVIDER', '').strip().lower() - - if not ai_provider or ai_provider in ['none', '']: - return # No AI provider selected - - if ai_provider not in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot']: - return # Unknown provider - - # Check if modules are available and offer installation if needed - await self.ai_manager.check_and_install_ai_modules_async(ai_provider, self.page) - - except Exception as e: - print(f"Error checking AI provider setup: {e}") - - def get_config(self): - """Get current configuration""" - return self.config.copy() - - def update_config(self, new_config): - """Update configuration""" - self.config.update(new_config) - self.config_manager.config = self.config.copy() - - def save_config(self, config_values): - """Save configuration""" - success = self.config_manager.save_configuration(config_values) - if success: - self.config = self.config_manager.get_config() - # Update dry run state - dry_run_config = self.config.get('DRY_RUN', 'false') - self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on') - return success - - def create_github_api(self, token=None, dry_run=None): - """Create a GitHub API instance""" - if token is None: - token = self.config.get('GITHUB_PAT', '') - if dry_run is None: - dry_run = self.dry_run_enabled - - logger = self.main_gui.logger if hasattr(self.main_gui, 'logger') else None - return GitHubAPI(token, logger, dry_run) - - def _on_setting_changed(self, key: str, value: any): - """ - Handle settings changes with live updates (no restart needed!) - - Args: - key: Setting key that changed - value: New value - """ - print(f"⚔ Setting changed: {key} = {value}") - - # Theme changes - apply immediately - if key == 'THEME_MODE': - if value == 'dark': - self.page.theme_mode = ft.ThemeMode.DARK - elif value == 'light': - self.page.theme_mode = ft.ThemeMode.LIGHT - self.page.update() - print(f"āœ“ Theme updated to {value}") - - # Dry run mode changes - elif key == 'DRY_RUN': - self.dry_run_enabled = str(value).lower() in ('true', '1', 'yes', 'on') - print(f"āœ“ Dry run mode: {self.dry_run_enabled}") - - # GitHub token changes - reinitialize API - elif key == 'GITHUB_PAT': - if hasattr(self, 'main_gui') and self.main_gui: - print("āœ“ GitHub token updated - API will be reinitialized on next use") - - # AI provider changes - elif key == 'AI_PROVIDER': - print(f"āœ“ AI provider changed to: {value}") - # AI manager will use new provider on next request - - # Update internal config - self.config[key] = value - - -async def main(page: ft.Page): - """Main entry point for Flet application""" - try: - app = GitHubAutomationApp(page) - except Exception as e: - # Show error as a simple text on the page since dialog can't open before page init - print(f"Failed to start application: {e}") - import traceback - traceback.print_exc() - - # Add error message to page - error_text = ft.Text( - f"Application Error:\n\n{str(e)}\n\nPlease check the console for details.", - color="red", - size=16, - ) - page.add(error_text) - - -if __name__ == "__main__": - # Run the Flet app with optimized settings - # For production builds, use appropriate view settings - is_production = getattr(sys, 'frozen', False) - - if is_production: - # Production build settings - ft.app( - target=main, - view=ft.AppView.FLET_APP, # Native app view for builds - assets_dir="assets" # Ensure assets are loaded correctly - ) - else: - # Development settings - ft.app( - target=main, - assets_dir="assets" - ) diff --git a/src/requirements/requirements-ai.txt b/src/requirements/requirements-ai.txt deleted file mode 100644 index c04851a..0000000 --- a/src/requirements/requirements-ai.txt +++ /dev/null @@ -1,11 +0,0 @@ -# AI Provider dependencies -# These are optional and only needed if using AI features - -# Include base requirements --r requirements-base.txt - -# AI Providers (optional - install only what you need) -openai>=2.8.0 # For ChatGPT integration -anthropic>=0.72.1 # For Claude integration - -# Note: Ollama and GitHub Copilot use REST APIs and don't require additional packages diff --git a/src/requirements/requirements-base.txt b/src/requirements/requirements-base.txt deleted file mode 100644 index 2b0d341..0000000 --- a/src/requirements/requirements-base.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Base requirements for all platforms -# Core dependencies required for the application to run - -# UI Framework (Flet - Python wrapper for Flutter) -flet==0.28.3 - -# HTTP requests for API calls -requests>=2.32.5 - -# Secure credential storage (cross-platform) -keyring>=25.6.0 - -# Git operations for repository management -GitPython>=3.1.45 diff --git a/src/requirements/requirements-dev.txt b/src/requirements/requirements-dev.txt deleted file mode 100644 index d9cde79..0000000 --- a/src/requirements/requirements-dev.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Development requirements -# Includes all dependencies for development, testing, and building - -# Include AI requirements (which includes base) --r requirements-ai.txt - -# Development tools -flet[all]==0.28.3 # Flet with all development extras - -# Build tools (optional) -# pyinstaller>=6.0.0 # Alternative bundler if needed -# cx-Freeze>=6.15.0 # Alternative bundler if needed diff --git a/src/requirements/requirements.txt b/src/requirements/requirements.txt deleted file mode 100644 index 5100b0d..0000000 --- a/src/requirements/requirements.txt +++ /dev/null @@ -1,23 +0,0 @@ -# GitHub Pulse - Main Requirements File -# This file includes all dependencies needed for production use - -# Core dependencies -requests>=2.32.5 # HTTP client for GitHub API and AI providers -keyring>=25.6.0 # Secure credential storage (cross-platform) -GitPython>=3.1.45 # Git operations for repository management - -# UI Framework - Flet (Python wrapper for Flutter) -flet==0.28.3 # Pin to specific version for build compatibility - -# AI Providers (optional but included for full functionality) -openai>=2.8.0 # ChatGPT/OpenAI API integration -anthropic>=0.72.1 # Claude/Anthropic API integration - -# Platform-specific notes: -# - Linux: Requires libgtk-3-dev, clang, cmake, ninja-build -# - Windows: Requires Visual Studio 2016+ -# - Android: Requires Android Studio and SDK -# - iOS/macOS: Requires Xcode - -# For minimal installation (no AI), use: pip install -r requirements-base.txt -# For development: pip install -r requirements-dev.txt \ No newline at end of file