diff --git a/.gitignore b/.gitignore index 65b5e4c..a4cadc1 100644 --- a/.gitignore +++ b/.gitignore @@ -189,7 +189,7 @@ windows/ web/ .flet build/ -pyproject.toml +*.toml # Configuration (generated during build, not in git) # Note: pyproject.toml is now tracked for proper builds diff --git a/src/app_components/__init__.py b/src/app_components/__init__.py new file mode 100644 index 0000000..5311d86 --- /dev/null +++ b/src/app_components/__init__.py @@ -0,0 +1,54 @@ +""" +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 new file mode 100644 index 0000000..b42fdc1 --- /dev/null +++ b/src/app_components/ai_action_planner.py @@ -0,0 +1,617 @@ +""" +AI Action Planner +Generates and executes action plans for GitHub issues and PRs using AI +""" + +import json +import re +import requests +from typing import List, Dict, Any, Optional, Callable +from pathlib import Path + + +class ActionPlan: + """Represents an AI-generated action plan""" + + def __init__(self, title: str, steps: List[Dict[str, Any]], context: Dict[str, Any]): + self.title = title + self.steps = steps # List of {description, file_path, changes, completed} + self.context = context # PR/Issue context + self.completed_steps = [] + self.failed_steps = [] + + def to_dict(self) -> Dict[str, Any]: + """Convert plan to dictionary""" + return { + 'title': self.title, + 'steps': self.steps, + 'context': self.context, + 'completed_steps': self.completed_steps, + 'failed_steps': self.failed_steps + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ActionPlan': + """Create plan from dictionary""" + plan = cls(data['title'], data['steps'], data['context']) + plan.completed_steps = data.get('completed_steps', []) + plan.failed_steps = data.get('failed_steps', []) + return plan + + +class OllamaProvider: + """Simple Ollama API provider for AI action planning""" + + def __init__(self, base_url: str, model: str, logger): + self.base_url = base_url.rstrip('/') + self.model = model + self.logger = logger + + def generate(self, prompt: str) -> Optional[str]: + """Generate a response from Ollama""" + try: + response = requests.post( + f"{self.base_url}/api/generate", + json={ + "model": self.model, + "prompt": prompt, + "stream": False + }, + timeout=120 + ) + response.raise_for_status() + result = response.json() + return result.get('response', '') + except Exception as e: + self.logger.log(f"āŒ Ollama API error: {str(e)}") + return None + + def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: + """Make changes to file content using Ollama""" + # Try direct replacement first + if old_text and old_text.strip() in file_content: + return file_content.replace(old_text.strip(), new_text.strip()) + + # Use Ollama to make intelligent changes + prompt = f"""You are a code modification assistant. Modify the following file according to the instructions. + +File: {file_path} + +Current Content: +``` +{file_content} +``` + +Instructions: {new_text} +{f'Additional context: {custom_instructions}' if custom_instructions else ''} + +Return ONLY the complete modified file content. Do not include explanations or markdown code blocks.""" + + return self.generate(prompt) + + +class AIActionPlanner: + """Generates and executes action plans using AI""" + + def __init__(self, ai_manager, logger, config_manager): + self.ai_manager = ai_manager + self.logger = logger + self.config_manager = config_manager + + def generate_plan(self, item, custom_instructions: str = "") -> Optional[ActionPlan]: + """ + Generate an action plan for a PR or Issue + + Args: + item: The PR or Issue (WorkflowItem object or dict) + custom_instructions: Optional user-provided instructions + + Returns: + ActionPlan object or None if generation failed + """ + # Handle both WorkflowItem objects and dictionaries + if hasattr(item, 'item_type'): + # It's a WorkflowItem object + item_type = item.item_type + item_number = item.number + title = item.title + body = item.body or '' + repo = getattr(item, 'repo', None) + else: + # It's a dictionary + item_type = item.get('type', 'unknown') + item_number = item.get('number') + title = item.get('title', 'Untitled') + body = item.get('body', '') + repo = item.get('repo') + + self.logger.log(f"šŸ¤– Generating action plan for {item_type} #{item_number}...") + + # Get AI provider + config = self.config_manager.get_config() + ai_provider_name = config.get('AI_PROVIDER', 'none').lower() + + if ai_provider_name == 'none' or not ai_provider_name: + self.logger.log("āŒ No AI provider configured. Please configure in Settings.") + return None + + # Get provider instance + provider = self._get_ai_provider(ai_provider_name, config) + if not provider: + return None + + # Generate the plan using AI + try: + self.logger.log(f"šŸ“¤ Calling AI provider: {type(provider).__name__}") + plan_text = self._call_ai_for_plan(provider, item_type, title, body, custom_instructions) + + if not plan_text: + self.logger.log("āŒ AI did not generate a plan (empty response)") + return None + + self.logger.log(f"šŸ“„ Received response from AI ({len(plan_text)} characters)") + self.logger.log(f"šŸ“„ Response preview: {plan_text[:200]}...") + + # Parse the plan + self.logger.log("šŸ” Parsing AI response into steps...") + steps = self._parse_plan(plan_text) + + if not steps: + self.logger.log("āŒ Could not parse action steps from AI response") + return None + + # Get repo from item or config + if repo is None: + repo = config.get('GITHUB_REPO', '') + + plan = ActionPlan( + title=f"Action Plan for {item_type.upper()} #{item_number}: {title}", + steps=steps, + context={ + 'item_type': item_type, + 'item_number': item_number, + 'item_title': title, + 'item_body': body, + 'repo': repo + } + ) + + self.logger.log(f"āœ… Generated plan with {len(steps)} steps") + return plan + + except Exception as e: + self.logger.log(f"āŒ Error generating plan: {str(e)}") + return None + + def _get_ai_provider(self, provider_name: str, config: Dict[str, Any]): + """Get the AI provider instance""" + try: + if provider_name in ['claude', 'anthropic']: + # Try both CLAUDE_API_KEY and ANTHROPIC_API_KEY for compatibility + api_key = config.get('CLAUDE_API_KEY') + if not api_key: + api_key = config.get('ANTHROPIC_API_KEY') + if not api_key: + self.logger.log("āŒ Claude API key not found in secure storage (tried both CLAUDE_API_KEY and ANTHROPIC_API_KEY)") + return None + self.logger.log("ā„¹ļø Initializing Claude provider...") + from . import ai_manager + provider = ai_manager.ClaudeProvider(api_key, self.logger) + self.logger.log("āœ… Claude provider initialized successfully") + return provider + + elif provider_name in ['chatgpt', 'openai']: + api_key = config.get('OPENAI_API_KEY') + if not api_key: + self.logger.log("āŒ OpenAI API key not found in secure storage") + return None + self.logger.log("ā„¹ļø Initializing ChatGPT provider...") + from . import ai_manager + provider = ai_manager.ChatGPTProvider(api_key, self.logger) + self.logger.log("āœ… ChatGPT provider initialized successfully") + return provider + + elif provider_name == 'ollama': + # Ollama doesn't need an API key, uses URL from config + ollama_url = config.get('OLLAMA_URL', 'http://localhost:11434') + ollama_model = config.get('OLLAMA_MODEL', 'llama2') + self.logger.log(f"ā„¹ļø Using Ollama at {ollama_url} with model {ollama_model}") + # Create a simple Ollama provider wrapper + return OllamaProvider(ollama_url, ollama_model, self.logger) + + else: + self.logger.log(f"āŒ Unsupported AI provider: {provider_name}") + return None + + except Exception as e: + self.logger.log(f"āŒ Error creating AI provider: {str(e)}") + return None + + def _call_ai_for_plan(self, provider, item_type: str, title: str, body: str, custom_instructions: str) -> Optional[str]: + """Call AI to generate an action plan""" + + prompt = f"""You are an expert software engineer tasked with creating an actionable plan to address a GitHub {item_type}. + +{item_type.upper()} Title: {title} + +{item_type.upper()} Description: +{body} + +{"Additional Instructions: " + custom_instructions if custom_instructions else ""} + +Please create a detailed action plan with specific, executable steps. For each step, specify: +1. What needs to be done (clear description) +2. Which file(s) need to be modified (if applicable) +3. What changes should be made (if applicable) + +Format your response as a JSON array of steps, where each step has: +- "description": A clear description of what to do +- "file_path": Path to the file to modify (or null if not file-specific) +- "changes": Description of changes to make (or null if not applicable) +- "action_type": One of ["modify_file", "create_file", "delete_file", "investigate", "test", "document"] + +Example format: +```json +[ + {{ + "description": "Fix the authentication bug in login handler", + "file_path": "src/auth/login.py", + "changes": "Update the password validation logic to handle special characters correctly", + "action_type": "modify_file" + }}, + {{ + "description": "Add unit tests for authentication", + "file_path": "tests/test_auth.py", + "changes": "Add test cases for special characters in passwords", + "action_type": "create_file" + }} +] +``` + +IMPORTANT: Return ONLY the JSON array, no other text before or after.""" + + try: + if isinstance(provider, OllamaProvider): + # Use Ollama + self.logger.log(f"šŸ¤– Calling Ollama AI to generate plan...") + return provider.generate(prompt) + + elif hasattr(provider, '_generate_updated_document'): + # Use Claude's document generation + self.logger.log(f"šŸ¤– Calling Claude AI to generate plan...") + import anthropic + client = anthropic.Anthropic(api_key=provider.api_key) + + message = client.messages.create( + model="claude-sonnet-4-5", + max_tokens=4096, + messages=[{"role": "user", "content": prompt}] + ) + + return message.content[0].text + + elif hasattr(provider, 'client'): + # Use OpenAI/ChatGPT + self.logger.log(f"šŸ¤– Calling ChatGPT AI to generate plan...") + response = provider.client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": prompt}], + max_tokens=4096 + ) + + self.logger.log(f"āœ… ChatGPT response received") + return response.choices[0].message.content + + else: + self.logger.log(f"āŒ Unknown provider type: {type(provider).__name__}") + return None + + except Exception as e: + self.logger.log(f"āŒ AI API call failed: {str(e)}") + import traceback + self.logger.log(f"āŒ Traceback: {traceback.format_exc()}") + return None + + def _parse_plan(self, plan_text: str) -> List[Dict[str, Any]]: + """Parse the AI-generated plan text into structured steps""" + + try: + # Extract JSON from response (might be wrapped in markdown) + json_match = re.search(r'```json\s*(\[.*?\])\s*```', plan_text, re.DOTALL) + if json_match: + json_text = json_match.group(1) + else: + # Try to find JSON array directly + json_match = re.search(r'\[.*\]', plan_text, re.DOTALL) + if json_match: + json_text = json_match.group(0) + else: + self.logger.log("āš ļø Could not find JSON in AI response") + return [] + + # Parse JSON + steps = json.loads(json_text) + + # Validate and clean up steps + validated_steps = [] + for i, step in enumerate(steps): + if isinstance(step, dict): + validated_step = { + 'step_number': i + 1, + 'description': step.get('description', f'Step {i+1}'), + 'file_path': step.get('file_path'), + 'changes': step.get('changes'), + 'action_type': step.get('action_type', 'investigate'), + 'completed': False, + 'status': 'pending' + } + validated_steps.append(validated_step) + + self.logger.log(f"āœ… Successfully parsed {len(validated_steps)} steps from AI response") + return validated_steps + + except json.JSONDecodeError as e: + self.logger.log(f"āŒ Failed to parse JSON: {str(e)}") + self.logger.log(f"Response was: {plan_text[:500]}...") + return [] + except Exception as e: + self.logger.log(f"āŒ Error parsing plan: {str(e)}") + return [] + + def execute_plan( + self, + plan: ActionPlan, + local_repo_path: str, + progress_callback: Optional[Callable[[int, int, str], None]] = None, + log_callback: Optional[Callable[[str], None]] = None + ) -> Dict[str, Any]: + """ + Execute an action plan + + Args: + plan: The ActionPlan to execute + local_repo_path: Path to local git repository + progress_callback: Callback function(current_step, total_steps, message) + log_callback: Callback function for logging thought process + + Returns: + Dictionary with execution results + """ + def log(message): + """Helper to log to both logger and callback""" + self.logger.log(message) + if log_callback: + log_callback(message) + + log(f"ā–¶ļø Starting execution of plan: {plan.title}") + + if not local_repo_path or not Path(local_repo_path).exists(): + log(f"āŒ Local repository path not found: {local_repo_path}") + return {'success': False, 'error': 'Invalid local repository path'} + + total_steps = len(plan.steps) + completed = 0 + failed = 0 + + for i, step in enumerate(plan.steps): + step_num = step['step_number'] + + # Mark step as in-progress + step['status'] = 'in_progress' + if progress_callback: + progress_callback(i + 1, total_steps, f"Executing step {step_num}...") + + log(f"\nšŸ“ Step {step_num}/{total_steps}: {step['description']}") + + try: + result = self._execute_step(step, local_repo_path, plan.context, log) + + if result['success']: + step['completed'] = True + step['status'] = 'completed' + plan.completed_steps.append(step_num) + completed += 1 + log(f"āœ… Step {step_num} completed") + else: + step['status'] = 'failed' + step['error'] = result.get('error', 'Unknown error') + plan.failed_steps.append(step_num) + failed += 1 + log(f"āŒ Step {step_num} failed: {result.get('error')}") + + except Exception as e: + step['status'] = 'failed' + step['error'] = str(e) + plan.failed_steps.append(step_num) + failed += 1 + log(f"āŒ Step {step_num} failed with exception: {str(e)}") + + log(f"\nšŸ“Š Execution complete: {completed}/{total_steps} steps successful, {failed} failed") + + # If we made changes successfully, commit and push them + if completed > 0: + try: + log("\nšŸ”§ Committing and pushing changes...") + + # Get PR/Issue info from context + item_type = plan.context.get('item_type', 'item') + item_number = plan.context.get('item_number', 'unknown') + item_title = plan.context.get('item_title', 'changes') + + # Commit message + commit_msg = f"AI: Execute action plan for {item_type} #{item_number}\n\n{item_title}\n\nAutomated changes by GitHub Pulse AI" + + # Get current branch (should be the PR branch) + import subprocess + result = subprocess.run( + ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + cwd=local_repo_path, + capture_output=True, + text=True, + timeout=10 + ) + current_branch = result.stdout.strip() if result.returncode == 0 else 'main' + log(f"šŸ“ Current branch: {current_branch}") + + # Stage all changes + log("šŸ“ Staging changes...") + subprocess.run(['git', 'add', '-A'], cwd=local_repo_path, check=True, timeout=10) + + # Check if there are changes to commit + result = subprocess.run( + ['git', 'diff', '--cached', '--quiet'], + cwd=local_repo_path, + timeout=10 + ) + + if result.returncode != 0: # There are changes + # Commit + log("šŸ’¾ Committing changes...") + subprocess.run( + ['git', 'commit', '-m', commit_msg], + cwd=local_repo_path, + check=True, + timeout=10 + ) + + # Push + log(f"šŸš€ Pushing to {current_branch}...") + subprocess.run( + ['git', 'push', 'origin', current_branch], + cwd=local_repo_path, + check=True, + timeout=30 + ) + log(f"āœ… Changes pushed to {current_branch}") + else: + log("ā„¹ļø No changes to commit") + + except subprocess.TimeoutExpired: + log("āš ļø Git operation timed out") + except subprocess.CalledProcessError as e: + log(f"āš ļø Git operation failed: {e}") + except Exception as e: + log(f"āš ļø Error during git commit/push: {str(e)}") + + return { + 'success': failed == 0, + 'completed': completed, + 'failed': failed, + 'total': total_steps, + 'plan': plan + } + + def _execute_step(self, step: Dict[str, Any], local_repo_path: str, context: Dict[str, Any], log=None) -> Dict[str, Any]: + """Execute a single step of the plan""" + + action_type = step.get('action_type', 'investigate') + file_path = step.get('file_path') + changes = step.get('changes') + + # Use log function if provided, otherwise fall back to logger + log_func = log if log else self.logger.log + + if action_type == 'modify_file' and file_path: + return self._modify_file(file_path, changes, local_repo_path, log_func) + + elif action_type == 'create_file' and file_path: + return self._create_file(file_path, changes, local_repo_path, log_func) + + elif action_type == 'delete_file' and file_path: + return self._delete_file(file_path, local_repo_path, log_func) + + else: + # For investigate, test, document actions, just mark as completed + # (requires manual intervention) + log_func(f"ā„¹ļø Manual action required: {step['description']}") + return {'success': True, 'message': 'Manual action logged'} + + def _modify_file(self, file_path: str, changes: str, local_repo_path: str, log=None) -> Dict[str, Any]: + """Modify a file using AI""" + + log_func = log if log else self.logger.log + full_path = Path(local_repo_path) / file_path + + if not full_path.exists(): + return {'success': False, 'error': f'File not found: {file_path}'} + + try: + log_func(f"šŸ“ Reading file: {file_path}") + # Read current content + with open(full_path, 'r', encoding='utf-8') as f: + current_content = f.read() + + # Get AI provider to make changes + config = self.config_manager.get_config() + provider_name = config.get('AI_PROVIDER', 'none').lower() + provider = self._get_ai_provider(provider_name, config) + + if not provider: + return {'success': False, 'error': 'AI provider not available'} + + # Use AI to make the changes + log_func(f"šŸ¤– Using AI to modify {file_path}...") + log_func(f"šŸ” Analyzing changes needed...") + updated_content = provider.make_change( + file_content=current_content, + old_text=current_content[:200] + "...", # Context + new_text=changes, # What to change + file_path=str(full_path), + custom_instructions=changes + ) + + if updated_content and updated_content != current_content: + log_func(f"šŸ’¾ Writing changes to {file_path}...") + # Write updated content + with open(full_path, 'w', encoding='utf-8') as f: + f.write(updated_content) + + log_func(f"āœ… Successfully modified {file_path}") + return {'success': True, 'file': file_path} + else: + return {'success': False, 'error': 'AI could not generate changes'} + + except Exception as e: + return {'success': False, 'error': f'Error modifying file: {str(e)}'} + + def _create_file(self, file_path: str, content: str, local_repo_path: str, log=None) -> Dict[str, Any]: + """Create a new file""" + + log_func = log if log else self.logger.log + full_path = Path(local_repo_path) / file_path + + if full_path.exists(): + return {'success': False, 'error': f'File already exists: {file_path}'} + + try: + log_func(f"šŸ“„ Creating new file: {file_path}") + # Create parent directories if needed + full_path.parent.mkdir(parents=True, exist_ok=True) + + # Create file with content + with open(full_path, 'w', encoding='utf-8') as f: + f.write(content or f"# TODO: Implement {file_path}\n") + + log_func(f"āœ… Created {file_path}") + return {'success': True, 'file': file_path} + + except Exception as e: + return {'success': False, 'error': f'Error creating file: {str(e)}'} + + def _delete_file(self, file_path: str, local_repo_path: str, log=None) -> Dict[str, Any]: + """Delete a file""" + + log_func = log if log else self.logger.log + full_path = Path(local_repo_path) / file_path + + if not full_path.exists(): + return {'success': False, 'error': f'File not found: {file_path}'} + + try: + log_func(f"šŸ—‘ļø Deleting file: {file_path}") + full_path.unlink() + log_func(f"āœ… Deleted {file_path}") + return {'success': True, 'file': file_path} + + except Exception as e: + return {'success': False, 'error': f'Error deleting file: {str(e)}'} diff --git a/src/app_components/ai_manager.py b/src/app_components/ai_manager.py new file mode 100644 index 0000000..0a24d30 --- /dev/null +++ b/src/app_components/ai_manager.py @@ -0,0 +1,3430 @@ +""" +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 new file mode 100644 index 0000000..f3aa649 Binary files /dev/null and b/src/app_components/assets/flow-diagram.png differ diff --git a/src/app_components/assets/github_pulse_img.png b/src/app_components/assets/github_pulse_img.png new file mode 100644 index 0000000..8b5f930 Binary files /dev/null and b/src/app_components/assets/github_pulse_img.png 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 new file mode 100644 index 0000000..8d03e89 Binary files /dev/null and b/src/app_components/assets/pulse_logo_gray_no_bkg.png 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 new file mode 100644 index 0000000..6159727 Binary files /dev/null and b/src/app_components/assets/pulse_logo_white_no_bkg.png 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 new file mode 100644 index 0000000..a525242 Binary files /dev/null and b/src/app_components/assets/pulse_logo_white_no_bkg_github.png 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 new file mode 100644 index 0000000..1e6b5c2 Binary files /dev/null and b/src/app_components/assets/pulse_logo_white_w_black_bkg.png differ diff --git a/src/app_components/cache_manager.py b/src/app_components/cache_manager.py new file mode 100644 index 0000000..077412c --- /dev/null +++ b/src/app_components/cache_manager.py @@ -0,0 +1,185 @@ +""" +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 new file mode 100644 index 0000000..d572328 --- /dev/null +++ b/src/app_components/config_manager.py @@ -0,0 +1,246 @@ +""" +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 new file mode 100644 index 0000000..a3ab660 --- /dev/null +++ b/src/app_components/github_api.py @@ -0,0 +1,985 @@ +""" +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 new file mode 100644 index 0000000..7739f8a --- /dev/null +++ b/src/app_components/main_gui.py @@ -0,0 +1,3123 @@ +""" +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 new file mode 100644 index 0000000..998549f --- /dev/null +++ b/src/app_components/processing_log_dialog.py @@ -0,0 +1,125 @@ +""" +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 new file mode 100644 index 0000000..ea53efe --- /dev/null +++ b/src/app_components/settings_dialog.py @@ -0,0 +1,1150 @@ +""" +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 new file mode 100644 index 0000000..c75c12e --- /dev/null +++ b/src/app_components/settings_manager.py @@ -0,0 +1,307 @@ +""" +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 new file mode 100644 index 0000000..63b8461 --- /dev/null +++ b/src/app_components/utils.py @@ -0,0 +1,662 @@ +""" +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 new file mode 100644 index 0000000..56eee04 --- /dev/null +++ b/src/app_components/workflow.py @@ -0,0 +1,653 @@ +""" +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 new file mode 100644 index 0000000..6159727 Binary files /dev/null and b/src/assets/icon.png differ diff --git a/src/assets/splash_android.png b/src/assets/splash_android.png new file mode 100644 index 0000000..6159727 Binary files /dev/null and b/src/assets/splash_android.png differ diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..056f2ed --- /dev/null +++ b/src/main.py @@ -0,0 +1,218 @@ +""" +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 new file mode 100644 index 0000000..c04851a --- /dev/null +++ b/src/requirements/requirements-ai.txt @@ -0,0 +1,11 @@ +# 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 new file mode 100644 index 0000000..2b0d341 --- /dev/null +++ b/src/requirements/requirements-base.txt @@ -0,0 +1,14 @@ +# 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 new file mode 100644 index 0000000..d9cde79 --- /dev/null +++ b/src/requirements/requirements-dev.txt @@ -0,0 +1,12 @@ +# 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 new file mode 100644 index 0000000..5100b0d --- /dev/null +++ b/src/requirements/requirements.txt @@ -0,0 +1,23 @@ +# 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