From feafbc15afead0edfe6e353d187d9486a9c7ee31 Mon Sep 17 00:00:00 2001 From: b-tsammmons <233864410+b-tsammons@users.noreply.github.com> Date: Wed, 12 Nov 2025 02:31:24 -1000 Subject: [PATCH] Updated how settings and API keys are stored - Reworked the GUI to use flet --- .gitignore | 5 +- application/app.py | 116 +- application/app_components/ai_manager.py | 22 +- application/app_components/config_manager.py | 424 +- application/app_components/main_gui.py | 4014 +++++------------ application/app_components/settings_dialog.py | 1747 ++++--- .../app_components/settings_manager.py | 307 ++ application/requirements.txt | 4 + 8 files changed, 2528 insertions(+), 4111 deletions(-) create mode 100644 application/app_components/settings_manager.py diff --git a/.gitignore b/.gitignore index 3bb62d6..15c4b97 100644 --- a/.gitignore +++ b/.gitignore @@ -207,4 +207,7 @@ marimo/_lsp/ __marimo__/ # AI Files -.claude/ \ No newline at end of file +.claude/ + +# Settings files +config.json \ No newline at end of file diff --git a/application/app.py b/application/app.py index 9943857..5469187 100644 --- a/application/app.py +++ b/application/app.py @@ -6,8 +6,10 @@ This application provides GitHub automation workflows with AI assistance. """ import sys -import tkinter as tk -from tkinter import messagebox +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: @@ -24,11 +26,26 @@ except ImportError as e: class GitHubAutomationApp: """Main application class that orchestrates all components""" - def __init__(self): + def __init__(self, page: ft.Page): """Initialize the application""" - self.root = tk.Tk() - self.root.title("GitHub Pulse") - self.root.geometry("1400x1000") + self.page = page + + # Configure page + self.page.title = "GitHub Pulse" + self.page.theme_mode = ft.ThemeMode.DARK + self.page.padding = 0 + + # Set window size + 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 + self.page.theme = ft.Theme( + color_scheme_seed="blue", + use_material3=True, + ) # Initialize core managers self.config_manager = ConfigManager() @@ -41,20 +58,30 @@ class GitHubAutomationApp: 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( - root=self.root, + page=self.page, config_manager=self.config_manager, ai_manager=self.ai_manager, app=self ) - # Set up AI provider check after GUI is ready - self.root.after(100, self._check_ai_provider_setup) + # Build UI + self.page.add(self.main_gui.build()) - def _check_ai_provider_setup(self): + # 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', '']: @@ -64,7 +91,7 @@ class GitHubAutomationApp: return # Unknown provider # Check if modules are available and offer installation if needed - self.ai_manager.check_and_install_ai_modules(ai_provider, self.root) + 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}") @@ -98,26 +125,63 @@ class GitHubAutomationApp: logger = self.main_gui.logger if hasattr(self.main_gui, 'logger') else None return GitHubAPI(token, logger, dry_run) - def run(self): - """Start the application""" - try: - self.root.mainloop() - except KeyboardInterrupt: - print("Application interrupted by user") - except Exception as e: - messagebox.showerror("Application Error", f"An unexpected error occurred:\n{str(e)}") - print(f"Application error: {e}") + 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 -def main(): - """Main entry point""" +async def main(page: ft.Page): + """Main entry point for Flet application""" try: - app = GitHubAutomationApp() - app.run() + 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}") - sys.exit(1) + 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__": - main() + # Run the Flet app + ft.app(target=main) diff --git a/application/app_components/ai_manager.py b/application/app_components/ai_manager.py index 23757bb..c7b0021 100644 --- a/application/app_components/ai_manager.py +++ b/application/app_components/ai_manager.py @@ -3271,7 +3271,27 @@ class AIManager: 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: diff --git a/application/app_components/config_manager.py b/application/app_components/config_manager.py index fe518b6..d572328 100644 --- a/application/app_components/config_manager.py +++ b/application/app_components/config_manager.py @@ -1,247 +1,216 @@ """ Configuration Manager -Handles loading/saving configuration from .env files and launch.json +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 from multiple sources""" - + """ + 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): - self.config = self.load_configuration() - - def _get_default_config(self) -> Dict[str, Any]: - """Get default configuration values""" - return { - 'GITHUB_PAT': None, - 'GITHUB_REPO': None, - 'FORKED_REPO': None, # User's fork repository - 'AI_PROVIDER': None, - 'CLAUDE_API_KEY': None, - 'OPENAI_API_KEY': None, - 'GITHUB_TOKEN': None, # For GitHub Copilot AI Provider - 'OLLAMA_URL': None, # Ollama server URL - 'OLLAMA_API_KEY': None, # Optional Ollama API key/password - 'OLLAMA_MODEL': None, # Selected Ollama model - 'LOCAL_REPO_PATH': None, - 'DRY_RUN': 'false', - 'CUSTOM_INSTRUCTIONS': None # Custom AI instructions - } - - def load_configuration(self) -> Dict[str, Any]: - """Load configuration from launch.json first, then .env as fallback""" - config = self._get_default_config() - launch_json_keys = set() - - # First, try to load from launch.json - launch_json_path = os.path.join('.vscode', 'launch.json') - if os.path.exists(launch_json_path): - try: - with open(launch_json_path, 'r', encoding='utf-8') as f: - launch_data = json.load(f) - - # Look for configurations with env variables - for configuration in launch_data.get('configurations', []): - env_vars = configuration.get('env', {}) - for key in config.keys(): - if key in env_vars and env_vars[key] and not env_vars[key].startswith('<'): - config[key] = env_vars[key] - launch_json_keys.add(key) - - if launch_json_keys: - print(f"Loaded configuration from launch.json: {launch_json_path}") - - except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: - print(f"Could not load launch.json: {e}") - - # Check if .env file exists, create default if not - if not os.path.exists('.env'): - print("No .env file found. Creating default .env file...") - self._create_default_env_file(config) - - # Load values from .env file (but don't override launch.json values) - if os.path.exists('.env'): - try: - env_loaded = False - with open('.env', 'r', encoding='utf-8') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#') and '=' in line: - key, value = line.split('=', 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - - # Load from .env if key exists in config and wasn't loaded from launch.json - if key in config and key not in launch_json_keys: - config[key] = value if value else '' - env_loaded = True - - if env_loaded: - print("Loaded configuration from .env file") - elif not launch_json_keys: - print("Configuration files found but no valid values loaded") - - except FileNotFoundError: - print("No .env file found") - except Exception as e: - print(f"Could not load .env file: {e}") - - # Ensure all config values are strings, not None - for key in config: - if config[key] is None: - config[key] = '' - - # Special handling for AI_PROVIDER - default to 'none' if empty - if not config.get('AI_PROVIDER'): - config['AI_PROVIDER'] = 'none' - - # Debug output - loaded_from = [] - for key, value in config.items(): - if value: - loaded_from.append(f"{key}: {'loaded' if value else 'not found'}") - - if loaded_from: - print(f"Configuration status: {', '.join(loaded_from)}") + """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 - all fields will be blank") - - self.config = config - return config - - def _create_default_env_file(self, config: Dict[str, Any]) -> None: - """Create a default .env file with empty values""" - try: - env_template = """# GitHub Pulse Configuration -# Generated automatically - fill in your values -# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore. + print("No configuration values loaded - using defaults") -# GitHub Configuration -GITHUB_PAT= -GITHUB_REPO= -FORKED_REPO= + def load_configuration(self) -> Dict[str, Any]: + """ + Load configuration from new system (config.json + keyring). -# Application Settings -DRY_RUN=false + Returns: + Dictionary of all settings + """ + self.config = self._settings.load() + self._apply_token_defaults() + return self.config -# AI Provider Configuration (for local PR creation with AI assistance) -AI_PROVIDER= -CLAUDE_API_KEY= -OPENAI_API_KEY= -GITHUB_TOKEN= -OLLAMA_URL= -OLLAMA_API_KEY= -OLLAMA_MODEL= -LOCAL_REPO_PATH= - -# Custom AI Instructions (optional) -CUSTOM_INSTRUCTIONS= -""" - - with open('.env', 'w', encoding='utf-8') as f: - f.write(env_template) - - print("Created default .env file with blank values") - - except Exception as e: - print(f"Error creating default .env file: {e}") - def save_configuration(self, config_values: Dict[str, Any]) -> bool: - """Save configuration to .env file""" - try: - print(f"DEBUG: Saving config values: {config_values}") - print(f"DEBUG: AI_PROVIDER value being saved: '{config_values.get('AI_PROVIDER', 'NOT_FOUND')}'") - - # Update internal config - for key, value in config_values.items(): - if key in self.config: - old_value = self.config[key] - new_value = value or '' - self.config[key] = new_value - if key == 'AI_PROVIDER': - print(f"DEBUG: Updated AI_PROVIDER from '{old_value}' to '{new_value}'") - - # Build .env file content - env_content = [] - env_content.append("# GitHub Pulse Configuration") - env_content.append("# Generated by Settings Dialog") - env_content.append("# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore.") - env_content.append("") + """ + Save configuration using new system. - env_content.append("# GitHub Configuration") - env_content.append(f"GITHUB_PAT={self.config.get('GITHUB_PAT', '')}") - env_content.append(f"GITHUB_REPO={self.config.get('GITHUB_REPO', '')}") - env_content.append(f"FORKED_REPO={self.config.get('FORKED_REPO', '')}") - env_content.append("") + No restart required - changes apply immediately! - env_content.append("# Application Settings") - dry_run_value = str(self.config.get('DRY_RUN', 'false')).lower() - env_content.append(f"DRY_RUN={dry_run_value}") - env_content.append("") + Args: + config_values: Settings to save - env_content.append("# AI Provider Configuration (for local PR creation with AI assistance)") - ai_provider_value = self.config.get('AI_PROVIDER', '') - print(f"DEBUG: Writing AI_PROVIDER to file: '{ai_provider_value}'") - env_content.append(f"AI_PROVIDER={ai_provider_value}") - env_content.append(f"CLAUDE_API_KEY={self.config.get('CLAUDE_API_KEY', '')}") - env_content.append(f"OPENAI_API_KEY={self.config.get('OPENAI_API_KEY', '')}") - env_content.append(f"GITHUB_TOKEN={self.config.get('GITHUB_TOKEN', '')}") - env_content.append(f"OLLAMA_URL={self.config.get('OLLAMA_URL', '')}") - env_content.append(f"OLLAMA_API_KEY={self.config.get('OLLAMA_API_KEY', '')}") - env_content.append(f"OLLAMA_MODEL={self.config.get('OLLAMA_MODEL', '')}") - env_content.append(f"LOCAL_REPO_PATH={self.config.get('LOCAL_REPO_PATH', '')}") - env_content.append("") + 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 - env_content.append("# Custom AI Instructions (optional)") - env_content.append(f"CUSTOM_INSTRUCTIONS={self.config.get('CUSTOM_INSTRUCTIONS', '')}") - env_content.append("") - - # Write to file - with open('.env', 'w', encoding='utf-8') as f: - f.write('\n'.join(env_content)) - - print("Configuration saved to .env file") - return True - - except Exception as e: - print(f"Error saving configuration: {e}") - return False - def get_config(self) -> Dict[str, Any]: - """Get current configuration with automatic GITHUB_TOKEN defaulting""" + """ + 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 GITHUB_TOKEN is empty or None + + # 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""" - return self.config.get(key, default) - + """ + 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)""" - return self.config.get(key, default) - + """ + 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""" - if key in self.config: - self.config[key] = value - + """ + 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() @@ -251,32 +220,27 @@ CUSTOM_INSTRUCTIONS= return json.load(f) except (json.JSONDecodeError, FileNotFoundError): pass - return {} - - def save_pr_counter(self, counter: Dict[str, int]) -> None: + 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: - # Ensure directory exists - os.makedirs(os.path.dirname(counter_file), exist_ok=True) with open(counter_file, 'w', encoding='utf-8') as f: json.dump(counter, f, indent=2) + return True except Exception as e: - print(f"Warning: Could not save PR counter: {e}") - - def get_next_pr_number(self, provider_key: str) -> int: - """ - Get the next PR number for a given provider. + print(f"Error saving PR counter: {e}") + return False - Args: - provider_key: Either the AI provider name ('chatgpt', 'claude') or 'gh_copilot' - - Returns: - The next PR number for this provider - """ + def increment_pr_counter(self) -> int: + """Increment and return the PR counter""" counter = self.load_pr_counter() - current_number = counter.get(provider_key, 0) - next_number = current_number + 1 - counter[provider_key] = next_number + counter['count'] = counter.get('count', 0) + 1 self.save_pr_counter(counter) - return next_number \ No newline at end of file + 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/application/app_components/main_gui.py b/application/app_components/main_gui.py index 5d7d963..b9219fb 100644 --- a/application/app_components/main_gui.py +++ b/application/app_components/main_gui.py @@ -1,2337 +1,911 @@ """ -Main GUI Interface +Main GUI Interface (Flet version) The primary user interface for the application """ -import tkinter as tk -from tkinter import ttk, messagebox, scrolledtext +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 -# Removed imports: WorkItemProcessor, AzureDevOpsAPI, DataverseAPI -# These were specific to Azure DevOps integration - - -class HyperlinkDialog: - """Dialog with clickable hyperlinks""" - - def __init__(self, parent, title: str, message: str, url: str): - self.result = None - self.url = url - - # Create dialog window - self.dialog = tk.Toplevel(parent) - self.dialog.title(title) - self.dialog.geometry("500x280") - self.dialog.transient(parent) - self.dialog.grab_set() - - # Center on parent - self.dialog.geometry("+%d+%d" % ( - parent.winfo_rootx() + 50, - parent.winfo_rooty() + 50 - )) - - # Message - message_label = tk.Label(self.dialog, text=message, wraplength=450, justify=tk.LEFT) - message_label.pack(pady=20, padx=20) - - # URL link - link_label = tk.Label(self.dialog, text=url, fg="blue", cursor="hand2", wraplength=450) - link_label.pack(pady=(0, 20), padx=20) - link_label.bind("", self._open_url) - - # Button frame - button_frame = tk.Frame(self.dialog) - button_frame.pack(pady=(0, 20)) - - # Copy Link button - copy_button = ttk.Button(button_frame, text="Copy Link", command=self._copy_link) - copy_button.pack(side=tk.LEFT, padx=5) - - # OK button - ok_button = ttk.Button(button_frame, text="OK", command=self._ok_clicked) - ok_button.pack(side=tk.LEFT, padx=5) - - # Focus and bindings - ok_button.focus_set() - self.dialog.bind('', lambda e: self._ok_clicked()) - self.dialog.bind('', lambda e: self._ok_clicked()) - - def _open_url(self, event=None): - """Open URL in browser""" - webbrowser.open(self.url) - - def _copy_link(self): - """Copy URL to clipboard""" - self.dialog.clipboard_clear() - self.dialog.clipboard_append(self.url) - self.dialog.update() # Required to finalize clipboard operation - - # Show a brief confirmation (update button text temporarily) - # Find the copy button and change its text - for widget in self.dialog.winfo_children(): - if isinstance(widget, tk.Frame): - for button in widget.winfo_children(): - if isinstance(button, ttk.Button) and button.cget('text') == 'Copy Link': - original_text = button.cget('text') - button.config(text='Copied!') - self.dialog.after(1500, lambda: button.config(text=original_text)) - break - - def _ok_clicked(self): - """Handle OK button click""" - self.result = True - self.dialog.destroy() - - def show(self): - """Show dialog and wait for result""" - self.dialog.wait_window() - return self.result 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, root, config_manager, ai_manager, app): - self.root = root + + 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 - - # API instances - self.azure_api = None - self.dataverse_api = None - + self.workflow_items = {} + self.current_workflow_items = [] + + # Repository data + self.target_repos = [] + self.forked_repos = {'local': [], 'github': []} + # Create dry run compatibility wrapper self.dry_run_var = DryRunVar(app) - - # Create GUI - self.create_gui() - - # Initialize logger after GUI is created - self.logger = Logger(self.log_text) - # Initialize work item processor - REMOVED (was Azure DevOps specific) - # self.work_item_processor = WorkItemProcessor(self.logger, self.config_manager.get_config()) - self.work_item_processor = None # Placeholder for future implementation + # 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.prev_button_ref = ft.Ref[ft.IconButton]() + self.next_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.item_counter_ref = ft.Ref[ft.Text]() + + # DataTable ref for all items + self.items_table_ref = ft.Ref[ft.DataTable]() + + # 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 diff display - self.update_diff_display("") - - # Auto-load cached items on startup - self.root.after(500, self._auto_load_cached_items) - - # Load custom instructions after GUI is ready - self.root.after(100, self._load_custom_instructions) - - def create_gui(self): - """Create the main GUI interface""" - # Configure custom styles - self._configure_styles() - - # Main frame with padding - main_frame = ttk.Frame(self.root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Configure grid weights - self.root.columnconfigure(0, weight=1) - self.root.rowconfigure(0, weight=1) - main_frame.columnconfigure(1, weight=1) - - # Create sections - self._create_title_section(main_frame) - self._create_controls_section(main_frame) - self._create_status_section(main_frame) - self._create_tabs_section(main_frame) - - def _configure_styles(self): - """Configure custom styles for the GUI""" - style = ttk.Style() - - # Grouped sections - style.configure('Config.TLabelframe', relief='solid', borderwidth=1) - style.configure('Config.TLabelframe.Label', font=('Arial', 11, 'bold')) - style.configure('WorkItem.TLabelframe', relief='solid', borderwidth=1) - style.configure('WorkItem.TLabelframe.Label', font=('Arial', 11, 'bold')) - - # Notebook tabs - style.configure('TNotebook.Tab', background='lightblue', foreground='black', padding=[10, 5]) - style.map('TNotebook.Tab', - background=[('selected', 'lightblue'), ('active', '#87CEEB')], - foreground=[('selected', 'black'), ('active', 'black')]) - - # Blue edit button - style.configure('BlueEdit.TButton', - background='#2196F3', foreground='black', font=('Arial', 9, 'bold'), - relief='raised', borderwidth=2, focuscolor='none') - style.map('BlueEdit.TButton', - background=[('active', '#1976D2'), ('pressed', '#0D47A1'), ('!disabled', '#2196F3')], - foreground=[('active', 'black'), ('pressed', 'black'), ('!disabled', 'black')], - relief=[('pressed', 'sunken'), ('!pressed', 'raised')]) - - # Orange save button - style.configure('OrangeSave.TButton', - background='#FF9800', foreground='black', font=('Arial', 9, 'bold'), - relief='raised', borderwidth=2, focuscolor='none') - style.map('OrangeSave.TButton', - background=[('active', '#F57C00'), ('pressed', '#E65100'), ('!disabled', '#FF9800')], - foreground=[('active', 'black'), ('pressed', 'black'), ('!disabled', 'black')], - relief=[('pressed', 'sunken'), ('!pressed', 'raised')]) - - # Green save button for custom instructions - style.configure('GreenSave.TButton', - background='#4CAF50', foreground='black', font=('Arial', 9, 'bold'), - relief='raised', borderwidth=2, focuscolor='none') - style.map('GreenSave.TButton', - background=[('active', '#388E3C'), ('pressed', '#2E7D32'), ('!disabled', '#4CAF50')], - foreground=[('active', 'black'), ('pressed', 'black'), ('!disabled', 'black')], - relief=[('pressed', 'sunken'), ('!pressed', 'raised')]) - - def _create_title_section(self, parent): - """Create title section with settings button""" - title_frame = ttk.Frame(parent) - title_frame.grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 20)) - title_frame.columnconfigure(0, weight=1) - - # Title - title_label = ttk.Label(title_frame, text="GitHub Pulse", - font=('Arial', 16, 'bold')) - title_label.grid(row=0, column=0, sticky=tk.W) - - # AI Modules button - self.ai_modules_button = ttk.Button(title_frame, text="🤖 AI Modules", - command=self.check_ai_modules_manual) - self.ai_modules_button.grid(row=0, column=1, sticky=tk.E, padx=(10, 5)) - - # Settings button - self.settings_button = ttk.Button(title_frame, text="⚙️ Settings", - command=self.open_settings) - self.settings_button.grid(row=0, column=2, sticky=tk.E, padx=(5, 0)) - - def _create_controls_section(self, parent): - """Create GitHub Tools section""" - # GitHub Tools group frame - tools_frame = ttk.LabelFrame(parent, text="🔧 GitHub Tools", - style='WorkItem.TLabelframe', padding="15") - tools_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 15), padx=5) - tools_frame.columnconfigure(1, weight=1) - - # Initialize workflow data - self.target_repos = [] - self.forked_repos = [] - self.workflow_items = [] - self.current_workflow_items = [] - - # Get current config - config = self.config_manager.get_config() - - # Row 0: Mode Selection - mode_frame = ttk.Frame(tools_frame) - mode_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10), padx=5) - - ttk.Label(mode_frame, text="Mode:", font=('Arial', 10, 'bold')).grid( - row=0, column=0, sticky=tk.W, padx=(0, 10)) - - self.tools_mode_var = tk.StringVar(value="action") - create_radio = ttk.Radiobutton(mode_frame, text="✏️ Create PR/Issue", variable=self.tools_mode_var, - value="create", command=self._on_mode_changed) - create_radio.grid(row=0, column=1, padx=(0, 15)) - - action_radio = ttk.Radiobutton(mode_frame, text="📋 Action Existing PR/Issue", variable=self.tools_mode_var, - value="action", command=self._on_mode_changed) - action_radio.grid(row=0, column=2, padx=(0, 10)) - - # Separator - ttk.Separator(tools_frame, orient='horizontal').grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(5, 10)) - - # Row 2: Target Repository - self.target_repo_label = ttk.Label(tools_frame, text="Target Repository:", font=('Arial', 10, 'bold')) - self.target_repo_label.grid(row=2, column=0, sticky=tk.W, pady=5, padx=5) - - target_frame = ttk.Frame(tools_frame) - target_frame.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5, padx=5) - target_frame.columnconfigure(0, weight=1) - - self.target_repo_var = tk.StringVar(value=config.get('GITHUB_REPO', '')) - self.target_repo_dropdown = ttk.Combobox(target_frame, textvariable=self.target_repo_var, - values=[''], width=60) - self.target_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) - self.target_repo_dropdown.bind('', self._on_target_repo_search) - self.target_repo_dropdown.bind('<>', lambda e: self._on_repo_selection_changed()) - - refresh_target_btn = ttk.Button(target_frame, text="🔄", width=3, - command=self._refresh_target_repos) - refresh_target_btn.grid(row=0, column=1, padx=(0, 2)) - - search_target_btn = ttk.Button(target_frame, text="🔍", width=3, - command=self._search_target_repos) - search_target_btn.grid(row=0, column=2) - - # Row 3: Forked Repository - self.forked_repo_label = ttk.Label(tools_frame, text="Forked Repository:", font=('Arial', 10, 'bold')) - self.forked_repo_label.grid(row=3, column=0, sticky=tk.W, pady=5, padx=5) - - self.fork_frame = ttk.Frame(tools_frame) - self.fork_frame.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5, padx=5) - self.fork_frame.columnconfigure(0, weight=1) - - self.forked_repo_var = tk.StringVar(value=config.get('FORKED_REPO', '')) - self.forked_repo_dropdown = ttk.Combobox(self.fork_frame, textvariable=self.forked_repo_var, - values=[''], width=60) - self.forked_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) - self.forked_repo_dropdown.bind('<>', lambda e: self._on_repo_selection_changed()) - - refresh_fork_btn = ttk.Button(self.fork_frame, text="🔄", width=3, - command=self._refresh_forked_repos) - refresh_fork_btn.grid(row=0, column=1, padx=(0, 2)) - - clone_fork_btn = ttk.Button(self.fork_frame, text="📥", width=3, - command=self._clone_forked_repo) - clone_fork_btn.grid(row=0, column=2) - - # Row 4: Action Mode Controls (View toggles and load button) - self.action_controls_row = ttk.Frame(tools_frame) - self.action_controls_row.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 5), padx=5) - self.action_controls_row.columnconfigure(2, weight=1) - - # Repo source toggle - ttk.Label(self.action_controls_row, text="View:", font=('Arial', 10, 'bold')).grid( - row=0, column=0, sticky=tk.W, padx=(0, 5)) - - self.repo_source_var = tk.StringVar(value="target") - target_radio = ttk.Radiobutton(self.action_controls_row, text="Target", variable=self.repo_source_var, - value="target", command=self._filter_workflow_items) - target_radio.grid(row=0, column=1, padx=(0, 5)) - - fork_radio = ttk.Radiobutton(self.action_controls_row, text="Fork", variable=self.repo_source_var, - value="fork", command=self._filter_workflow_items) - fork_radio.grid(row=0, column=2, padx=(0, 15)) - - # Item type toggle - self.item_type_var = tk.StringVar(value="pull_request") - pr_radio = ttk.Radiobutton(self.action_controls_row, text="PRs", variable=self.item_type_var, - value="pull_request", command=self._filter_workflow_items) - pr_radio.grid(row=0, column=3, padx=(0, 5)) - - issue_radio = ttk.Radiobutton(self.action_controls_row, text="Issues", variable=self.item_type_var, - value="issue", command=self._filter_workflow_items) - issue_radio.grid(row=0, column=4, padx=(0, 15)) - - # Fetch button - self.fetch_workflow_btn = ttk.Button(self.action_controls_row, text="📥 Load Items", - command=self._load_workflow_items) - self.fetch_workflow_btn.grid(row=0, column=5, padx=(0, 10)) - - # Item counter - self.item_counter_label = ttk.Label(self.action_controls_row, text="No items loaded", - font=('Arial', 9, 'italic')) - self.item_counter_label.grid(row=0, column=6, sticky=tk.E) - - # Row 5: Workflow items dropdown (Action Mode) - self.action_item_label = ttk.Label(tools_frame, text="Select Item:", font=('Arial', 10, 'bold')) - self.action_item_label.grid(row=5, column=0, sticky=tk.W, pady=5, padx=5) - - action_item_dropdown_frame = ttk.Frame(tools_frame) - action_item_dropdown_frame.grid(row=5, column=1, sticky=(tk.W, tk.E), pady=5, padx=5) - action_item_dropdown_frame.columnconfigure(0, weight=1) - - self.workflow_item_var = tk.StringVar() - self.workflow_item_dropdown = ttk.Combobox(action_item_dropdown_frame, textvariable=self.workflow_item_var, - values=[''], width=60, state='readonly') - self.workflow_item_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E)) - self.workflow_item_dropdown.bind('<>', self._on_workflow_item_selected) - - # Row 4-5: Create Mode Controls (hidden by default) - self.create_controls_row = ttk.Frame(tools_frame) - self.create_controls_row.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 5), padx=5) - self.create_controls_row.columnconfigure(1, weight=1) - - # Create type selection - ttk.Label(self.create_controls_row, text="Create:", font=('Arial', 10, 'bold')).grid( - row=0, column=0, sticky=tk.W, padx=(0, 5)) - - self.create_type_var = tk.StringVar(value="pull_request") - create_pr_radio = ttk.Radiobutton(self.create_controls_row, text="📝 Pull Request", - variable=self.create_type_var, value="pull_request") - create_pr_radio.grid(row=0, column=1, padx=(0, 15), sticky=tk.W) - - create_issue_radio = ttk.Radiobutton(self.create_controls_row, text="🐛 Issue", - variable=self.create_type_var, value="issue") - create_issue_radio.grid(row=0, column=2, padx=(0, 15), sticky=tk.W) - - # Create button - self.create_item_btn = ttk.Button(self.create_controls_row, text="✏️ Create New", - command=self._create_new_item) - self.create_item_btn.grid(row=0, column=3, padx=(0, 10)) - - # Store references for show/hide - self.action_mode_widgets = [ - self.action_controls_row, - self.action_item_label, - action_item_dropdown_frame - ] - - self.create_mode_widgets = [ - self.create_controls_row - ] - - # Initialize mode (show action, hide create) - self._on_mode_changed() - - # Start loading repos - self.root.after(100, self._init_load_repos) - - def _create_status_section(self, parent): - """Create progress and status section""" - # Progress bar - self.progress = ttk.Progressbar(parent, mode='indeterminate') - self.progress.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) - - # Status label - self.status_label = ttk.Label(parent, text="Ready to fetch work items...") - self.status_label.grid(row=6, column=0, columnspan=3, pady=5) - - def _create_tabs_section(self, parent): - """Create tabbed interface section""" - # Create notebook - self.notebook = ttk.Notebook(parent) - self.notebook.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=10) - parent.rowconfigure(7, weight=1) - - # Create tabs - self._create_current_item_tab(self.notebook) - self._create_diff_tab(self.notebook) - self._create_log_tab(self.notebook) - self._create_all_items_tab(self.notebook) - - def _create_current_item_tab(self, notebook): - """Create current work item tab""" - item_frame = ttk.Frame(notebook) - notebook.add(item_frame, text="Current Work Item") - item_frame.columnconfigure(1, weight=1) - - # Work Item ID - ttk.Label(item_frame, text="Work Item ID:", font=('Arial', 10, 'bold')).grid( - row=0, column=0, sticky=tk.W, pady=5, padx=5) - self.work_item_id_label = ttk.Label(item_frame, text="Not loaded") - self.work_item_id_label.grid(row=0, column=1, sticky=tk.W, pady=5, padx=5) - self.work_item_id_label.bind("", self.open_work_item_url) - self.work_item_id_label.bind("", self.on_work_item_hover_enter) - self.work_item_id_label.bind("", self.on_work_item_hover_leave) - - # Nature of Request - ttk.Label(item_frame, text="Nature of Request:", font=('Arial', 10, 'bold')).grid( - row=1, column=0, sticky=tk.W, pady=5, padx=5) - self.nature_text = tk.Text(item_frame, height=1, width=70, state='disabled', wrap=tk.WORD) - self.nature_text.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5, padx=5) - - # Document URL - ttk.Label(item_frame, text="Live Doc URL:", font=('Arial', 10, 'bold')).grid( - row=2, column=0, sticky=tk.W, pady=5, padx=5) - self.doc_url_text = tk.Text(item_frame, height=1, width=70, state='disabled', wrap=tk.WORD) - self.doc_url_text.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5, padx=5) - - # Text to Change - ttk.Label(item_frame, text="Text to Change:", font=('Arial', 10, 'bold')).grid( - row=3, column=0, sticky=tk.W, pady=5, padx=5) - self.text_to_change_display = scrolledtext.ScrolledText(item_frame, height=5, width=70, state='disabled') - self.text_to_change_display.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5, padx=5) - - # Proposed New Text with Edit functionality - new_text_frame = ttk.Frame(item_frame) - new_text_frame.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=5, padx=5) - new_text_frame.columnconfigure(1, weight=1) - - ttk.Label(new_text_frame, text="Proposed New Text:", font=('Arial', 10, 'bold')).grid( - row=0, column=0, sticky=tk.W, pady=5) - - self.edit_button = ttk.Button(new_text_frame, text="✏️ Edit", - command=self.toggle_edit_mode, state='disabled', - style='BlueEdit.TButton') - self.edit_button.grid(row=0, column=1, sticky=tk.E, pady=5, padx=(5, 0)) - - self.new_text_display = scrolledtext.ScrolledText(new_text_frame, height=5, width=70, state='disabled') - self.new_text_display.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=5) - - # Custom AI Instructions with Save functionality - custom_instructions_frame = ttk.Frame(item_frame) - custom_instructions_frame.grid(row=5, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=5, padx=5) - custom_instructions_frame.columnconfigure(1, weight=1) - - ttk.Label(custom_instructions_frame, text="Custom AI Instructions:", font=('Arial', 10, 'bold')).grid( - row=0, column=0, sticky=tk.W, pady=5) - - # Button frame to hold both save and clear buttons - button_frame = ttk.Frame(custom_instructions_frame) - button_frame.grid(row=0, column=1, sticky=tk.E, pady=5, padx=(5, 0)) - - self.save_instructions_button = ttk.Button(button_frame, text="💾 Save", - command=self.save_custom_instructions, - style='GreenSave.TButton') - self.save_instructions_button.grid(row=0, column=0, padx=(0, 5)) - - self.clear_instructions_button = ttk.Button(button_frame, text="🗑️ Clear", - command=self.clear_custom_instructions) - self.clear_instructions_button.grid(row=0, column=1) - - self.custom_instructions_display = scrolledtext.ScrolledText(custom_instructions_frame, height=4, width=70) - self.custom_instructions_display.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=5) - - # Configure row weights - for i in range(6): - item_frame.rowconfigure(i, weight=1) - - def _create_diff_tab(self, notebook): - """Create diff view tab""" - diff_frame = ttk.Frame(notebook) - notebook.add(diff_frame, text="View Diff") - - diff_frame.columnconfigure(0, weight=1) - diff_frame.rowconfigure(1, weight=1) # Give weight to row 1 (diff_text) instead of row 0 (header) - - # Add a header label - header_frame = ttk.Frame(diff_frame) - header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=5, pady=5) - header_frame.columnconfigure(0, weight=1) - - ttk.Label(header_frame, text="Diff Viewer", font=('Arial', 12, 'bold')).grid( - row=0, column=0, sticky=tk.W, pady=5) - - # Add button to find existing diff files - self.find_diff_button = ttk.Button(header_frame, text="Find .diff Files", - command=self.find_and_load_diff_files) - self.find_diff_button.grid(row=0, column=1, sticky=tk.E, pady=5, padx=(0, 5)) - - self.clear_diff_button = ttk.Button(header_frame, text="Clear Diff", - command=self.clear_diff_display, state='disabled') - self.clear_diff_button.grid(row=0, column=2, sticky=tk.E, pady=5) - - # Create the diff display area with syntax highlighting-like colors - self.diff_text = scrolledtext.ScrolledText(diff_frame, - font=('Courier New', 9), - state='disabled', - bg='#f8f8f8') - self.diff_text.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=5, pady=5) - - # Configure diff syntax highlighting tags - self.diff_text.tag_config('diff_header', foreground='#0066cc', font=('Courier New', 9, 'bold')) - self.diff_text.tag_config('diff_file', foreground='#666666', font=('Courier New', 9, 'bold')) - self.diff_text.tag_config('diff_add', foreground='#008800', background='#e8ffe8') - self.diff_text.tag_config('diff_remove', foreground='#cc0000', background='#ffe8e8') - self.diff_text.tag_config('diff_context', foreground='#666666') - self.diff_text.tag_config('diff_line_numbers', foreground='#999999') - - def _create_log_tab(self, notebook): - """Create processing log tab""" - log_frame = ttk.Frame(notebook) - notebook.add(log_frame, text="Processing Log") - - log_frame.columnconfigure(0, weight=1) - log_frame.rowconfigure(0, weight=1) - - self.log_text = scrolledtext.ScrolledText(log_frame, height=25, width=100) - self.log_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=5, pady=5) - - def _create_all_items_tab(self, notebook): - """Create all work items tab""" - items_frame = ttk.Frame(notebook) - notebook.add(items_frame, text="All Work Items") - - items_frame.columnconfigure(0, weight=1) - items_frame.rowconfigure(0, weight=1) # Treeview gets the weight - # Row 1 (button frame) will not have weight, so it stays fixed size - - # Treeview for all items - columns = ('ID', 'Title', 'Nature', 'GitHub Repo', 'ms.author', 'Status') - self.items_tree = ttk.Treeview(items_frame, columns=columns, show='headings', height=20) - - # Define headings - self.items_tree.heading('ID', text='Work Item ID', anchor=tk.W) - self.items_tree.heading('Title', text='Title', anchor=tk.W) - self.items_tree.heading('Nature', text='Nature of Request', anchor=tk.W) - self.items_tree.heading('GitHub Repo', text='GitHub Repository', anchor=tk.W) - self.items_tree.heading('ms.author', text='ms.author', anchor=tk.W) - self.items_tree.heading('Status', text='Processing Status', anchor=tk.W) - - # Configure columns - self.items_tree.column('ID', width=100, anchor=tk.W) - self.items_tree.column('Title', width=220, anchor=tk.W) - self.items_tree.column('Nature', width=160, anchor=tk.W) - self.items_tree.column('GitHub Repo', width=160, anchor=tk.W) - self.items_tree.column('ms.author', width=100, anchor=tk.W) - self.items_tree.column('Status', width=100, anchor=tk.W) - - self.items_tree.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=5, pady=5) - - # Add selection functionality - self.items_tree.bind('', self._on_item_double_click) - self.items_tree.bind('<>', self._on_item_select) - - # Add button frame for selection actions - button_frame = ttk.Frame(items_frame) - button_frame.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), padx=5, pady=5) - - self.select_item_button = ttk.Button(button_frame, text="� Set as Current Item", - command=self._select_current_item, state='disabled') - self.select_item_button.pack(side=tk.LEFT, padx=5) - - ttk.Label(button_frame, text="Double-click an item or use the button above to set it as the current work item", - font=('Arial', 9), foreground='#666666').pack(side=tk.LEFT, padx=10) - - # Scrollbar - items_scrollbar = ttk.Scrollbar(items_frame, orient=tk.VERTICAL, command=self.items_tree.yview) - items_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - self.items_tree.configure(yscrollcommand=items_scrollbar.set) - - # Track selected item for enabling/disabling button - self.selected_tree_item = None - - # Event handlers and methods - def update_status(self, message: str): - """Update status label""" - self.status_label.config(text=message) - self.root.update_idletasks() - - def _check_ai_modules_manual(self): - """Manually check AI modules""" - config = self.config_manager.get_config() - ai_provider = config.get('AI_PROVIDER', '').strip().lower() - self.ai_manager.show_ai_modules_info(ai_provider, self.root) - - def _open_settings(self): - """Open settings dialog""" - try: - config = self.config_manager.get_config() - dialog = SettingsDialog(self.root, config, self.config_manager, self.cache_manager) - result = dialog.show() - - if result: - # Reload configuration - self.config_manager.load_configuration() - config = self.config_manager.get_config() - self.app.update_config(config) - - # Update dry run state - dry_run_config = config.get('DRY_RUN', 'false') - self.app.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on') - - self.update_status("✅ Settings saved and loaded successfully!") - - except Exception as e: - messagebox.showerror("Error", f"Failed to open settings dialog:\n{str(e)}") - - def _start_fetch_work_items(self): - """Start fetching work items""" - config = self.config_manager.get_config() - query_url = config.get('AZURE_DEVOPS_QUERY', '').strip() - azure_token = config.get('AZURE_DEVOPS_PAT', '').strip() - - if not query_url: - messagebox.showerror("Error", "Please enter an Azure DevOps Query URL in Settings") - return - - if not azure_token: - messagebox.showerror("Error", "Please enter your Azure DevOps token in Settings") - return - - # Clear previous data - self._clear_data() - - # Start processing thread - thread = threading.Thread(target=self._fetch_work_items, args=(query_url, azure_token)) - thread.daemon = True - thread.start() - - def _start_fetch_uuf_items(self): - """Start fetching UUF items""" - config = self.config_manager.get_config() - - # Check configuration - required_fields = [ - 'DATAVERSE_ENVIRONMENT_URL', - 'DATAVERSE_TABLE_NAME', - 'AZURE_AD_CLIENT_ID', - 'AZURE_AD_CLIENT_SECRET', - 'AZURE_AD_TENANT_ID' - ] - - if not all(config.get(field) for field in required_fields): - messagebox.showerror( - "Configuration Missing", - "PowerApp/Dataverse configuration is not complete.\n\n" - "Please ensure all required fields are set in Settings." - ) - return - - # Clear previous data - self._clear_data() - - # Start processing thread - thread = threading.Thread(target=self._fetch_uuf_items) - thread.daemon = True - thread.start() - - def _clear_data(self): - """Clear previous data""" - self.current_work_items = [] - self.current_item_index = 0 - self._clear_current_item_display() - self._clear_all_items_tree() - - def _auto_load_cached_items(self): - """Automatically load cached items on app startup""" - try: - config = self.config_manager.get_config() - - # Try to load Azure DevOps cache first - query_url = config.get('AZURE_DEVOPS_QUERY', '').strip() - azure_token = config.get('AZURE_DEVOPS_PAT', '').strip() - - if query_url and azure_token: - cache_id = query_url - cached_items = self.cache_manager.load_from_cache('azure_devops', cache_id) - - if cached_items: - self.logger.log("=== Auto-loading cached work items ===") - self.logger.log(f"✅ Loaded {len(cached_items)} items from cache") - self.current_work_items = cached_items - - # Setup Azure API for operations - temp_api = AzureDevOpsAPI("", azure_token, self.logger) - org, _, _ = temp_api.parse_query_url(query_url) - self.current_organization = org - self.azure_api = AzureDevOpsAPI(org, azure_token, self.logger) - - self._update_after_fetch() - self.update_status(f"Loaded {len(cached_items)} items from cache") - return - - # Try to load UUF cache if Azure DevOps cache not available - uuf_env_url = config.get('DATAVERSE_ENVIRONMENT_URL', '').strip() - uuf_table = config.get('DATAVERSE_TABLE_NAME', '').strip() - - if uuf_env_url and uuf_table: - cache_id = f"{uuf_env_url}_{uuf_table}" - cached_items = self.cache_manager.load_from_cache('uuf', cache_id) - - if cached_items: - self.logger.log("=== Auto-loading cached UUF items ===") - self.logger.log(f"✅ Loaded {len(cached_items)} items from cache") - self.current_work_items = cached_items - - # Setup Dataverse API for operations - self.dataverse_api = DataverseAPI(config, self.logger) - - self._update_after_fetch() - self.update_status(f"Loaded {len(cached_items)} UUF items from cache") - return - - # No cache available - self.logger.log("No cached items found") - - except Exception as e: - self.logger.log(f"⚠️ Error auto-loading cache: {str(e)}") - - def _fetch_work_items(self, query_url: str, azure_token: str): - """Fetch work items from Azure DevOps (always from server)""" - try: - self.fetch_button.config(state='disabled') - self.progress.start() - - cache_id = query_url - self.update_status("Fetching work items from Azure DevOps...") - self.logger.log("=== Fetching work items from Azure DevOps ===") - - # Initialize Azure DevOps API - temp_api = AzureDevOpsAPI("", azure_token, self.logger) - - # Parse query URL - org, project, query_id = temp_api.parse_query_url(query_url) - self.current_organization = org - self.logger.log(f"Parsed query - Org: {org}, Project: {project}, Query ID: {query_id}") - - # Create proper API instance - self.azure_api = AzureDevOpsAPI(org, azure_token, self.logger) - - # Execute query and process items - work_items = self.azure_api.execute_query(org, project, query_id, azure_token) - self.logger.log(f"Found {len(work_items)} work items") - - # Process items - self.current_work_items = [] - for item in work_items: - # REMOVED: Azure DevOps specific processing - # processed_item = self.work_item_processor.process_work_item(item) - # TODO: Implement custom item processing here - processed_item = item # Placeholder - if processed_item: - self.current_work_items.append(processed_item) - - self.logger.log(f"Successfully processed {len(self.current_work_items)} work items") - - # Save to cache - if self.cache_manager.save_to_cache('azure_devops', cache_id, self.current_work_items): - self.logger.log("✅ Work items cached for faster loading next time") - - # Update GUI - self._update_after_fetch() - - except Exception as e: - error_msg = f"Error fetching work items: {str(e)}" - self.logger.log(error_msg) - self.update_status("Fetch failed!") - messagebox.showerror("Fetch Error", error_msg) - finally: - self.progress.stop() - self.fetch_button.config(state='normal') - - def _fetch_uuf_items(self): - """Fetch UUF items from Dataverse (always from server)""" - try: - self.fetch_uuf_button.config(state='disabled') - self.progress.start() - - config = self.config_manager.get_config() - - # Create cache ID from config - cache_id = f"{config.get('DATAVERSE_ENVIRONMENT_URL')}_{config.get('DATAVERSE_TABLE_NAME')}" - - self.update_status("Fetching UUF items from PowerApp/Dataverse...") - self.logger.log("=== Fetching UUF items from Dataverse ===") - - # Initialize Dataverse API - self.dataverse_api = DataverseAPI( - config['DATAVERSE_ENVIRONMENT_URL'], - config['DATAVERSE_TABLE_NAME'], - self.logger - ) - - # Authenticate and fetch - auth_success = self.dataverse_api.authenticate( - config['AZURE_AD_CLIENT_ID'], - config['AZURE_AD_CLIENT_SECRET'], - config['AZURE_AD_TENANT_ID'] - ) - - if not auth_success: - raise RuntimeError("Failed to authenticate with Azure AD") - - uuf_items = self.dataverse_api.fetch_uuf_items() - self.logger.log(f"Found {len(uuf_items)} UUF items") - - # Process items - self.current_work_items = [] - for item in uuf_items: - # REMOVED: UUF/Dataverse specific processing - # processed_item = self.work_item_processor.process_uuf_item(item) - # TODO: Implement custom item processing here - processed_item = item # Placeholder - if processed_item: - self.current_work_items.append(processed_item) - - self.logger.log(f"Successfully processed {len(self.current_work_items)} UUF items") - - # Save to cache - if self.cache_manager.save_to_cache('uuf', cache_id, self.current_work_items): - self.logger.log("✅ UUF items cached for faster loading next time") - - # Update GUI - self._update_after_fetch() - - except Exception as e: - error_msg = f"Error fetching UUF items: {str(e)}" - self.logger.log(error_msg) - self.update_status("Fetch failed!") - messagebox.showerror("Fetch Error", error_msg) - finally: - self.progress.stop() - self.fetch_uuf_button.config(state='normal') - - def _update_after_fetch(self): - """Update GUI after successful fetch""" - self._update_all_items_tree() - if self.current_work_items: - self.current_item_index = 0 - self._display_current_item() - self._update_navigation_buttons() - self.update_status(f"Loaded {len(self.current_work_items)} items") - else: - self.update_status("No valid items found") - - def _clear_current_item_display(self): - """Clear current item display""" - self.work_item_id_label.config(text="Not loaded", foreground="black", cursor="") - - # Clear text widgets - for widget in [self.nature_text, self.doc_url_text, self.text_to_change_display, self.new_text_display]: - widget.config(state='normal') - widget.delete(1.0, tk.END) - widget.config(state='disabled') - - # Reset edit mode - self.edit_mode = False - self.edit_button.config(text="✏️ Edit", state='disabled', style='BlueEdit.TButton') - - def _clear_all_items_tree(self): - """Clear all items tree""" - for item in self.items_tree.get_children(): - self.items_tree.delete(item) - - def _display_current_item(self): - """Display 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 work item ID with hyperlink styling - self.work_item_id_label.config( - text=f"#{item['id']} - {item['title']}", - foreground="blue", - cursor="hand2" + # Initialize logger + self.logger = None # Will be set after UI is created + + # 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.PSYCHOLOGY, + tooltip="Check AI Modules", + on_click=self._check_ai_modules_manual, + ), + 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, ) - - # Update text fields - self._update_text_widget(self.nature_text, item['nature_of_request']) - self._update_text_widget(self.doc_url_text, item['mydoc_url']) - self._update_text_widget(self.text_to_change_display, item['text_to_change']) - self._update_text_widget(self.new_text_display, item['new_text']) - - # Reset edit mode - self.edit_mode = False - self.edit_button.config(text="✏️ Edit", state='normal', style='BlueEdit.TButton') - - # Update dropdown based on source - if item.get('source') == 'UUF': - self.action_type_dropdown.set("Create PR") - self.action_type_dropdown.config(state='disabled') - else: - self.action_type_dropdown.config(state='readonly') - - # Update counter - self.item_counter_label.config(text=f"Item {self.current_item_index + 1} of {len(self.current_work_items)}") - - # Update highlighting in All Work Items treeview - self._update_treeview_selection() - - def _update_text_widget(self, widget, text): - """Update a text widget with new content""" - widget.config(state='normal') - widget.delete(1.0, tk.END) - widget.insert(1.0, text) - widget.config(state='disabled') - - def _update_all_items_tree(self): - """Update all items treeview""" - self._clear_all_items_tree() - current_item_id = None - - # Get current item ID if available - if hasattr(self, 'current_work_items') and self.current_work_items and hasattr(self, 'current_item_index'): - if 0 <= self.current_item_index < len(self.current_work_items): - current_item_id = self.current_work_items[self.current_item_index]['id'] - - for item in self.current_work_items: - nature_preview = item['nature_of_request'][:50] + "..." if len(item['nature_of_request']) > 50 else item['nature_of_request'] - - github_info = item.get('github_info', {}) - github_repo = "" - ms_author = "" - - if github_info.get('owner') and github_info.get('repo'): - github_repo = f"{github_info['owner']}/{github_info['repo']}" - elif github_info.get('error'): - github_repo = "Error extracting" - else: - github_repo = "Not determined" - - ms_author = github_info.get('ms_author') or "Not found" - - item_id = self.items_tree.insert('', 'end', values=( - item['id'], - item['title'][:40] + "..." if len(item['title']) > 40 else item['title'], - nature_preview, - github_repo, - ms_author, - item['status'] - )) - - # Highlight the current item - if current_item_id and item['id'] == current_item_id: - self.items_tree.selection_set(item_id) - self.items_tree.focus(item_id) - # Configure a tag for highlighting the current item - self.items_tree.set(item_id, 'Status', f"★ {item['status']}") # Add star to status - def _update_treeview_selection(self): - """Update the selection highlighting in the All Work Items treeview to match current item""" - if not hasattr(self, 'items_tree') or not self.current_work_items: + # 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, + ) + + # Overall layout: Top nav + bottom section + app_layout = ft.Column( + [ + top_nav, + ft.Divider(height=1), + bottom_section, + ], + 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._auto_load_cached_items() + await self._load_custom_instructions() + await self._init_load_repos() + + 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.PSYCHOLOGY, + tooltip="Check AI Modules", + on_click=self._check_ai_modules_manual, + ), + 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("View", weight=ft.FontWeight.BOLD), + ft.RadioGroup( + ref=self.repo_source_ref, + content=ft.Row([ + ft.Radio(value="target", label="Target"), + ft.Radio(value="fork", label="Fork"), + ]), + value="target", + on_change=lambda e: self._filter_workflow_items(), + ), + ft.Text("Item Type", weight=ft.FontWeight.BOLD), + ft.RadioGroup( + ref=self.item_type_ref, + content=ft.Row([ + ft.Radio(value="pull_request", label="PRs"), + ft.Radio(value="issue", label="Issues"), + ]), + value="pull_request", + on_change=lambda e: self._filter_workflow_items(), + ), + ft.Row([ + ft.ElevatedButton( + "📥 Load Items", + 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.Dropdown( + ref=self.workflow_item_dropdown_ref, + label="Select Workflow Item", + hint_text="Select an item", + options=[], + expand=True, + on_change=self._on_workflow_item_selected, + ), + ], + 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="Processing Log", + icon=ft.icons.LIST_ALT, + content=self._create_log_tab() + ), + ft.Tab( + text="All Items", + icon=ft.icons.VIEW_LIST, + content=self._create_all_items_tab() + ), + ], + expand=True, + ) + + return ft.Container( + content=tabs, + expand=True, + ) + + def _create_current_item_tab(self) -> ft.Container: + """Create the current item tab""" + # Navigation buttons + nav_buttons = ft.Row( + [ + ft.IconButton( + ref=self.prev_button_ref, + icon=ft.icons.ARROW_BACK, + tooltip="Previous", + on_click=self._previous_item, + disabled=True, + ), + ft.IconButton( + ref=self.next_button_ref, + icon=ft.icons.ARROW_FORWARD, + tooltip="Next", + on_click=self._next_item, + disabled=True, + ), + ft.Container(expand=True), + ft.ElevatedButton( + "Go", + ref=self.go_button_ref, + icon=ft.icons.PLAY_ARROW, + on_click=self._create_github_resource, + disabled=True, + ), + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ) + + # Work Item ID (clickable) + work_item_id = ft.Text( + ref=self.work_item_id_ref, + value="No item selected", + size=16, + weight=ft.FontWeight.BOLD, + color="blue", + ) + + # Fields + nature_text = ft.TextField( + ref=self.nature_text_ref, + label="Nature of Request", + multiline=True, + min_lines=2, + max_lines=4, + read_only=True, + expand=True, + ) + + live_doc_url = ft.TextField( + ref=self.live_doc_url_ref, + label="Live Doc URL", + read_only=True, + expand=True, + ) + + text_to_change = ft.TextField( + ref=self.text_to_change_ref, + label="Text to Change", + multiline=True, + min_lines=5, + max_lines=10, + read_only=True, + expand=True, + ) + + # Proposed New Text with Edit button + proposed_header = ft.Row( + [ + ft.Text("Proposed New Text", weight=ft.FontWeight.BOLD), + ft.Container(expand=True), + ft.IconButton( + ref=self.edit_button_ref, + icon=ft.icons.EDIT, + tooltip="Edit", + on_click=self._toggle_edit_mode, + disabled=True, + ), + ], + ) + + proposed_new_text = ft.TextField( + ref=self.proposed_new_text_ref, + multiline=True, + min_lines=5, + max_lines=10, + read_only=True, + expand=True, + ) + + # Custom Instructions + custom_instructions_header = ft.Row( + [ + ft.Text("Custom AI Instructions", weight=ft.FontWeight.BOLD), + ft.Container(expand=True), + ft.IconButton( + icon=ft.icons.SAVE, + tooltip="Save Instructions", + on_click=self.save_custom_instructions, + ), + ft.IconButton( + icon=ft.icons.DELETE, + tooltip="Clear Instructions", + on_click=self.clear_custom_instructions, + ), + ], + ) + + custom_instructions = ft.TextField( + ref=self.custom_instructions_ref, + hint_text="Enter custom instructions for AI processing...", + multiline=True, + min_lines=3, + max_lines=6, + expand=True, + ) + + return ft.Container( + content=ft.ListView( + controls=[ + nav_buttons, + work_item_id, + ft.Divider(), + nature_text, + live_doc_url, + text_to_change, + proposed_header, + proposed_new_text, + ft.Divider(), + custom_instructions_header, + custom_instructions, + ], + spacing=15, + 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_log_tab(self) -> ft.Container: + """Create the processing log tab""" + log_text = ft.TextField( + ref=self.log_text_ref, + multiline=True, + read_only=True, + expand=True, + text_style=ft.TextStyle(font_family="Courier New"), + ) + + return ft.Container( + content=log_text, + 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("ID")), + ft.DataColumn(ft.Text("Title")), + ft.DataColumn(ft.Text("Nature")), + ft.DataColumn(ft.Text("GitHub Repo")), + ft.DataColumn(ft.Text("ms.author")), + ft.DataColumn(ft.Text("Status")), + ], + rows=[], + ) + + 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() + + def _on_workflow_item_selected(self, e): + """Handle workflow item selection""" + if not self.workflow_item_dropdown_ref.current: return - - try: - # Get current item ID - if not (0 <= self.current_item_index < len(self.current_work_items)): - return - - current_item_id = self.current_work_items[self.current_item_index]['id'] - - # Clear current selection - self.items_tree.selection_remove(self.items_tree.selection()) - - # Find and select the current item in the treeview - for item_id in self.items_tree.get_children(): - item_values = self.items_tree.item(item_id, 'values') - if item_values and item_values[0] == current_item_id: - self.items_tree.selection_set(item_id) - self.items_tree.focus(item_id) - self.items_tree.see(item_id) # Scroll to make sure it's visible + + 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 - - except Exception as e: - # Silently handle errors to avoid disrupting the UI - pass - - def _update_navigation_buttons(self): - """Update navigation button states""" - has_items = len(self.current_work_items) > 0 - - self.prev_button.config(state='normal' if has_items and self.current_item_index > 0 else 'disabled') - self.next_button.config(state='normal' if has_items and self.current_item_index < len(self.current_work_items) - 1 else 'disabled') - - # Enable GO button if current item has valid GitHub info - if has_items: - current_item = self.current_work_items[self.current_item_index] - github_info = current_item['github_info'] - has_valid_github = github_info.get('owner') and github_info.get('repo') - self.go_button.config(state='normal' if has_valid_github else 'disabled') + + def _filter_workflow_items(self): + """Filter workflow items based on current selections""" + print("=" * 60) + print("FILTER METHOD CALLED") + print("=" * 60) + + if not self.repo_source_ref.current or not self.item_type_ref.current: + print("ERROR: repo_source or item_type ref not available") + if self.logger: + self.logger.log("Cannot filter: repo source or item type not selected") + return + + source = self.repo_source_ref.current.value + item_type = self.item_type_ref.current.value + print(f"DEBUG: source='{source}', item_type='{item_type}'") + + # Map item_type to the correct key suffix + # "pull_request" → "prs", "issue" → "issues" + if item_type == "pull_request": + type_suffix = "prs" + elif item_type == "issue": + type_suffix = "issues" else: - self.go_button.config(state='disabled') - - def _previous_item(self): + type_suffix = f"{item_type}s" + + key = f"{source}_{type_suffix}" + print(f"DEBUG: Mapped item_type '{item_type}' to suffix '{type_suffix}'") + print(f"DEBUG: Looking for key '{key}'") + print(f"DEBUG: Available keys in workflow_items: {list(self.workflow_items.keys())}") + + self.current_workflow_items = self.workflow_items.get(key, []) + print(f"DEBUG: Found {len(self.current_workflow_items)} items for key '{key}'") + + if self.logger: + self.logger.log(f"Filtering workflow items: source={source}, type={item_type}, key={key}") + self.logger.log(f"Available workflow item keys: {list(self.workflow_items.keys())}") + self.logger.log(f"Found {len(self.current_workflow_items)} items for key '{key}'") + + # Update dropdown + if self.workflow_item_dropdown_ref.current: + options = [] + for item in self.current_workflow_items: + if hasattr(item, 'title'): + options.append(ft.dropdown.Option(item.title)) + print(f" - Added item: {item.title}") + else: + print(f" - WARNING: Item has no title attribute: {item}") + + print(f"DEBUG: Created {len(options)} dropdown options") + self.workflow_item_dropdown_ref.current.options = options + + if self.item_counter_ref.current: + count_text = f"{len(options)} item(s) loaded" + if len(options) == 0: + count_text = f"No {item_type}s found in {source} repo" + 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") + else: + print("ERROR: workflow_item_dropdown_ref.current is None!") + + def _display_workflow_item(self, item): + """Display a workflow item""" + # Implementation would populate fields with workflow item data + pass + + 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 _next_item(self): + + def _next_item(self, e): """Navigate to next item""" if self.current_item_index < len(self.current_work_items) - 1: self.current_item_index += 1 self._display_current_item() self._update_navigation_buttons() - - def _toggle_edit_mode(self): + + def _toggle_edit_mode(self, e): """Toggle edit mode for proposed new text""" - if not self.current_work_items or self.current_item_index >= len(self.current_work_items): + if not self.proposed_new_text_ref.current or not self.edit_button_ref.current: return - - if not self.edit_mode: - # Enter edit mode - self.edit_mode = True - self.new_text_display.config(state='normal') - self.edit_button.config(text="💾 Save", style='OrangeSave.TButton') - self.logger.log(f"Editing mode enabled for work item #{self.current_work_items[self.current_item_index]['id']}") + + 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 changes - current_item = self.current_work_items[self.current_item_index] - new_text = self.new_text_display.get(1.0, tk.END).strip() - current_item['new_text'] = new_text - - self.edit_mode = False - self.new_text_display.config(state='disabled') - self.edit_button.config(text="✏️ Edit", style='BlueEdit.TButton') - - self.logger.log(f"Proposed new text updated for work item #{current_item['id']}") - messagebox.showinfo("Saved", "Proposed new text has been updated!") - - def _load_custom_instructions(self): - """Load custom instructions from config on startup""" - try: - config = self.config_manager.get_config() - custom_instructions = config.get('CUSTOM_INSTRUCTIONS', '') - - # Set the text in the custom instructions display - if hasattr(self, 'custom_instructions_display'): - self.custom_instructions_display.delete('1.0', tk.END) - if custom_instructions: - self.custom_instructions_display.insert('1.0', custom_instructions) - except Exception as e: - self.logger.log(f"Error loading custom instructions: {str(e)}") - - def save_custom_instructions(self): - """Save custom instructions to .env file""" - try: - # Get the current instructions from the text widget - current_instructions = self.custom_instructions_display.get('1.0', tk.END).strip() - - # Save to config - config_values = {'CUSTOM_INSTRUCTIONS': current_instructions} - success = self.config_manager.save_configuration(config_values) - - if success: - self.logger.log("Custom AI instructions saved to .env file") - messagebox.showinfo("Saved", "Custom AI instructions have been saved to .env file!") - else: - self.logger.log("Failed to save custom AI instructions") - messagebox.showerror("Error", "Failed to save custom AI instructions to .env file.") - - except Exception as e: - self.logger.log(f"Error saving custom instructions: {str(e)}") - messagebox.showerror("Error", f"Error saving custom instructions: {str(e)}") - - def clear_custom_instructions(self): - """Clear custom instructions from both UI and .env file""" - try: - # Clear the text widget - if hasattr(self, 'custom_instructions_display'): - self.custom_instructions_display.delete('1.0', tk.END) - - # Save empty value to config - config_values = {'CUSTOM_INSTRUCTIONS': ''} - success = self.config_manager.save_configuration(config_values) - - if success: - self.logger.log("Custom AI instructions cleared from .env file") - messagebox.showinfo("Cleared", "Custom AI instructions have been cleared!") - else: - self.logger.log("Failed to clear custom AI instructions") - messagebox.showerror("Error", "Failed to clear custom AI instructions from .env file.") - - except Exception as e: - self.logger.log(f"Error clearing custom instructions: {str(e)}") - messagebox.showerror("Error", f"Error clearing custom instructions: {str(e)}") - - def _extract_file_path_from_github_url(self, url: str) -> str: - """Extract file path from GitHub URL - - Example: https://github.com/owner/repo/blob/main/path/to/file.md -> path/to/file.md - """ - if not url or 'github.com' not in url or '/blob/' not in url: - return '' - - try: - # Split by /blob/ to separate the repo part from the file part - parts = url.split('/blob/', 1) - if len(parts) != 2: - return '' - - # Split the second part by / to get branch and file path - path_parts = parts[1].split('/', 1) - if len(path_parts) == 2: - # Return everything after the branch name - return path_parts[1] - except Exception as e: - self.logger.log(f"Warning: Failed to extract file path from URL {url}: {e}") - - return '' - - def _on_work_item_hover_enter(self, event=None): - """Handle mouse enter on work item ID""" - if self.current_work_items and self.current_item_index < len(self.current_work_items): - self.work_item_id_label.configure(font=('Arial', 10, 'underline')) - - def _on_work_item_hover_leave(self, event=None): - """Handle mouse leave on work item ID""" - if self.current_work_items and self.current_item_index < len(self.current_work_items): - self.work_item_id_label.configure(font=('Arial', 10)) - - def _open_work_item_url(self, event=None): - """Open work item URL in browser""" - if not self.current_work_items or self.current_item_index >= len(self.current_work_items): + # 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 - - item = self.current_work_items[self.current_item_index] - work_item_id = item['id'] - - if self.current_organization: - work_item_url = f"https://dev.azure.com/{self.current_organization}/_workitems/edit/{work_item_id}" - webbrowser.open(work_item_url) - self.logger.log(f"Opened work item #{work_item_id} in browser: {work_item_url}") + + 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: - messagebox.showwarning("Warning", "Organization not available. Cannot open work item URL.") - - def _create_github_resource(self): - """Create GitHub resource (PR) with cross-repository support and repository verification""" + 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""" try: - if not self.current_work_items or self.current_item_index >= len(self.current_work_items): - messagebox.showerror("Error", "No work item selected") - return - - # Get current work item first - current_item = self.current_work_items[self.current_item_index] - - # Get configuration - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '').strip() - target_repo = config.get('GITHUB_REPO', '').strip() # Where PR will be created - forked_repo = config.get('FORKED_REPO', '').strip() # User's fork where changes will be made - local_repo_path = config.get('LOCAL_REPO_PATH', '').strip() - - if not github_token and not self.dry_run_var.get(): - messagebox.showerror("Error", "Please configure your GitHub token in Settings or enable dry run mode") - return - - if not target_repo: - messagebox.showerror("Configuration Error", "GitHub target repository not configured.") - return - - # Use forked repo for changes, fall back to target repo if not specified - source_repo = forked_repo if forked_repo else target_repo - - # Check if AI provider is configured to determine workflow - ai_provider = config.get('AI_PROVIDER', 'none').strip().lower() - use_ai_workflow = ai_provider and ai_provider not in ['none', ''] - - # If using AI workflow, automatically ensure local repository exists - if use_ai_workflow and local_repo_path: - work_item_repo = self._get_work_item_repository(current_item) - if work_item_repo: - self.logger.log(f"🔄 AI workflow detected - ensuring repository {work_item_repo} is available locally...") - try: - self._ensure_local_repo(work_item_repo, local_repo_path, github_token) - except Exception as e: - self.logger.log(f"⚠️ Could not ensure local repository: {str(e)}") - # Continue anyway - the AI workflow may still work - - # Determine if creating issue or PR - is_uuf = current_item.get('source') == 'UUF' - create_pr = self.action_type_var.get() == "Create PR" - - # Start appropriate workflow in separate thread - if is_uuf or (create_pr and not use_ai_workflow): - # Use cross-repo workflow for UUF items or PRs without AI - thread = threading.Thread(target=self._process_cross_repo_pr, args=(source_repo, target_repo)) - elif create_pr and use_ai_workflow: - # Use AI-assisted workflow for PRs with AI provider configured - thread = threading.Thread(target=self._process_github_pr_with_verification, args=(target_repo, source_repo)) - else: - # Create GitHub issue - thread = threading.Thread(target=self._process_github_issue) - - thread.daemon = True - thread.start() - + # Try to load from cache + if self.cache_manager: + # Implementation would load cached items + pass except Exception as e: - self.logger.log(f"❌ Error in _create_github_resource: {str(e)}") - messagebox.showerror("Error", f"Failed to create GitHub resource: {str(e)}") - - def _process_cross_repo_pr(self, source_repo: str, target_repo: str): - """Process cross-repository PR creation with auto-cloning""" - try: - self.go_button.config(state='disabled') - self.progress.start() - - # Get current work item and config - current_item = self.current_work_items[self.current_item_index] - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '') - local_repo_path = config.get('LOCAL_REPO_PATH', '') - - # If no source repo specified, try to auto-detect from forked repo config - if not source_repo or source_repo == target_repo: - source_repo = config.get('FORKED_REPO', '') - if not source_repo: - # Try to extract from document URL or use target repo - github_info = current_item.get('github_info', {}) - doc_url = github_info.get('mydoc_url', '') - if doc_url and 'github.com' in doc_url: - # Try to detect repo from URL - source_repo = self._detect_repo_from_url(doc_url, github_token) - - if not source_repo: - source_repo = target_repo - - # Parse repository information - try: - if '/' not in target_repo: - raise ValueError("Invalid target repository format") - target_owner, target_repo_name = target_repo.split('/', 1) - - if '/' not in source_repo: - raise ValueError("Invalid source repository format") - source_owner, source_repo_name = source_repo.split('/', 1) - except ValueError as e: - self.logger.log(f"❌ Repository format error: {e}") - messagebox.showerror("Configuration Error", - f"Invalid repository format. Use 'owner/repo' format.\n" - f"Target: {target_repo}\nSource: {source_repo}") - return - - # Check if local repository exists, clone if needed - if local_repo_path and source_owner != target_owner: - local_source_path = self._ensure_local_repo(source_repo, local_repo_path, github_token) - if local_source_path: - self.logger.log(f"Using local repository: {local_source_path}") - - # Initialize GitHub API - github_api = self.app.create_github_api(github_token) - github_info = current_item['github_info'] - - # Create a unique branch name - from .utils import PRNumberManager - pr_number = PRNumberManager.get_next_pr_number("cross_repo") - branch_name = f"docs-update-{pr_number}" - - self.logger.log("=== Starting Cross-Repository PR Creation ===") - self.logger.log(f"Source Repository: {source_owner}/{source_repo_name}") - self.logger.log(f"Target Repository: {target_owner}/{target_repo_name}") - self.logger.log(f"Branch Name: {branch_name}") - - # Step 1: Create branch in source repository with placeholder commit - self.logger.log("Creating branch with placeholder commit in source repository...") - - # Build instructions for the placeholder - instructions = f""" -Work Item #{current_item.get('id', 'unknown')}: {current_item.get('title', 'Update documentation')} + print(f"Error auto-loading cached items: {e}") -**Description:** -{current_item.get('description', 'No description available')} - -**Changes needed:** -{current_item.get('new_text', 'See work item details')} -""" - - if not github_api.create_branch_with_placeholder(source_owner, source_repo_name, branch_name, instructions): - self.logger.log("❌ Failed to create branch with placeholder in source repository") - messagebox.showerror("Error", "Failed to create branch with placeholder in source repository.") - return - - # Step 2: Make documentation changes if AI provider is configured - ai_provider = config.get('AI_PROVIDER', 'none').strip().lower() - if ai_provider and ai_provider not in ['none', '']: - self.logger.log(f"AI provider ({ai_provider}) configured - attempting AI-assisted changes...") - - # Try to make documentation changes if we have a file path - if github_info.get('file_path'): - self.logger.log("Making AI-assisted documentation changes...") - - file_path = github_info['file_path'] - old_text = current_item.get('text_to_change', '') - new_text = current_item.get('new_text', '') - commit_message = f"Update documentation - Work Item #{current_item.get('id', 'unknown')}" - - if github_api.make_documentation_change( - source_owner, source_repo_name, branch_name, - file_path, old_text, new_text, commit_message - ): - self.logger.log("✅ Documentation changes committed successfully") - else: - self.logger.log("⚠️ Failed to make documentation changes, continuing with PR creation...") - else: - # No file path specified, but AI provider is configured - # The AI-assisted workflow should handle this in the full PR creation process - self.logger.log("ℹ️ AI provider configured but no specific file path - will use AI in PR workflow") - else: - self.logger.log("ℹ️ Using placeholder commit for PR creation (no AI provider configured)") - - # Step 3: Create Pull Request - from .utils import ContentBuilders - pr_title = ContentBuilders.build_pr_title(current_item) - pr_body = ContentBuilders.build_pr_body(current_item, github_info) - - if source_owner != target_owner or source_repo_name != target_repo_name: - # Cross-repository PR - self.logger.log("Creating cross-repository pull request...") - pr_id, pr_url, pr_num = github_api.create_cross_repo_pull_request( - source_owner, source_repo_name, target_owner, target_repo_name, - pr_title, pr_body, branch_name - ) - else: - # Same repository PR - self.logger.log("Creating pull request in same repository...") - target_repo_id = github_api.get_repo_id(target_owner, target_repo_name) - pr_id, pr_url, pr_num = github_api.create_pull_request( - target_repo_id, pr_title, pr_body, branch_name - ) - - # Step 4: Handle GitHub Copilot workflow based on AI provider setting - ai_provider = config.get('AI_PROVIDER', 'none').strip().lower() - - if ai_provider and ai_provider not in ['none', '']: - # AI provider is configured - skip Copilot assignment and comments - self.logger.log(f"✅ Using AI provider ({ai_provider}) - Skipping GitHub Copilot @mention workflow") - else: - # No AI provider - use GitHub Copilot workflow - self.logger.log("Using GitHub Copilot workflow (no AI provider configured)") - - # Assign to GitHub Copilot if available - copilot_actor_id, copilot_login = github_api.get_copilot_actor_id(target_owner, target_repo_name) - if copilot_actor_id: - self.logger.log(f"Assigning PR to GitHub Copilot ({copilot_login})...") - success = github_api.assign_to_copilot(pr_id, [copilot_actor_id]) - if not success: - self.logger.log("ℹ️ Copilot assignment failed due to permissions - this is normal for many repositories") - self.logger.log(" The @copilot comment below will still notify Copilot to work on the PR") - else: - self.logger.log("ℹ️ GitHub Copilot not available for assignment in this repository") - - # Add Copilot comment with instructions - self.logger.log("Adding Copilot instruction comment...") - file_path = github_info.get('file_path', '') - - # Extract file path from GitHub URLs if not already set - if not file_path: - # Try extracting from mydoc_url if it's a GitHub URL - mydoc_url = github_info.get('mydoc_url', '') - if mydoc_url: - extracted_path = self._extract_file_path_from_github_url(mydoc_url) - if extracted_path: - file_path = extracted_path - self.logger.log(f"Extracted file path from GitHub URL: {file_path}") - else: - file_path = f"File path not specified in work item (URL: {mydoc_url})" - else: - file_path = "See work item description for file details" - - # Get custom instructions from config - custom_instructions = config.get('CUSTOM_INSTRUCTIONS', '') - - github_api.add_copilot_comment( - target_owner, target_repo_name, pr_num, - file_path, - current_item.get('text_to_change', ''), - current_item.get('new_text', ''), - branch_name, - str(current_item.get('id', 'unknown')), - current_item.get('source', 'Work Item'), - github_info.get('mydoc_url', ''), - custom_instructions - ) - - self.logger.log(f"✅ @copilot comment added with work instructions") - if copilot_actor_id: - self.logger.log(f"📋 Note: Check the PR to see if Copilot assignment worked or needs manual assignment") - - self.logger.log(f"✅ Cross-repository PR created successfully: {pr_url}") - - # Show success dialog with hyperlink - self.root.after(0, lambda: HyperlinkDialog( - self.root, - "PR Created Successfully!", - f"Pull request created successfully!\n\n" - f"Source: {source_owner}/{source_repo_name}:{branch_name}\n" - f"Target: {target_owner}/{target_repo_name}\n" - f"PR Number: #{pr_num}", - pr_url - ).show()) - - except Exception as e: - error_msg = f"Failed to create cross-repository PR: {str(e)}" - self.logger.log(f"❌ {error_msg}") - self.root.after(0, lambda: messagebox.showerror("Error", error_msg)) - finally: - self.root.after(0, lambda: self.progress.stop()) - self.root.after(0, lambda: self.go_button.config(state='normal')) - - def _ensure_local_repo(self, repo_name: str, local_path: str, github_token: str) -> Optional[str]: - """Ensure local repository exists, clone if needed""" - try: - from .utils import LocalRepositoryScanner - - repo_folder = repo_name.split('/')[-1] # Get just the repo name - local_repo_path = os.path.join(local_path, repo_folder) - - if os.path.exists(local_repo_path): - # Check if it's actually a Git repo - if os.path.exists(os.path.join(local_repo_path, '.git')): - self.logger.log(f"Local repository already exists: {local_repo_path}") - return local_repo_path - else: - self.logger.log(f"Directory exists but not a Git repo: {local_repo_path}") - - # Need to clone - self.logger.log(f"Cloning repository {repo_name} to {local_repo_path}") - repo_url = f"https://github.com/{repo_name}.git" - - if LocalRepositoryScanner.clone_repository(repo_url, local_path, repo_name): - return local_repo_path - else: - self.logger.log(f"❌ Failed to clone repository {repo_name}") - return None - - except Exception as e: - self.logger.log(f"❌ Error ensuring local repo: {str(e)}") - return None - - def _detect_repo_from_url(self, doc_url: str, github_token: str) -> str: - """Detect user's fork repository from document URL""" - try: - # Extract the base repo from URL - from urllib.parse import urlparse - parsed = urlparse(doc_url) - - if 'docs.microsoft.com' in parsed.netloc: - # Try to map Microsoft Docs URL to repository - if 'fabric' in doc_url.lower(): - base_repo = 'fabric-docs' - elif 'azure' in doc_url.lower(): - base_repo = 'azure-docs' - elif 'powerbi' in doc_url.lower(): - base_repo = 'powerbi-docs' - else: - return '' - - # Get user's forks to find matching repo - github_api = self.app.create_github_api(github_token) - user_forks = github_api.get_user_forks() - - for fork in user_forks: - if base_repo in fork: - self.logger.log(f"Auto-detected forked repository: {fork}") - return fork - - except Exception as e: - self.logger.log(f"Error detecting repo from URL: {str(e)}") - - return '' - - def _get_work_item_repository(self, work_item: Dict[str, Any]) -> str: - """Extract repository name from work item""" - try: - # First check if github_info has repo information - github_info = work_item.get('github_info', {}) - if github_info.get('owner') and github_info.get('repo'): - return f"{github_info['owner']}/{github_info['repo']}" - - # Try to detect from mydoc_url - doc_url = work_item.get('mydoc_url', '') - if doc_url and 'github.com' in doc_url: - # Parse GitHub URL to extract repo - from urllib.parse import urlparse - parsed = urlparse(doc_url) - path_parts = parsed.path.strip('/').split('/') - if len(path_parts) >= 2: - return f"{path_parts[0]}/{path_parts[1]}" - - # Try to infer from docs URL - if doc_url and 'docs.microsoft.com' in doc_url: - if 'fabric' in doc_url.lower(): - return 'microsoftdocs/fabric-docs' - elif 'azure' in doc_url.lower(): - return 'microsoftdocs/azure-docs' - elif 'powerbi' in doc_url.lower(): - return 'microsoftdocs/powerbi-docs' - - return '' - - except Exception as e: - self.logger.log(f"Error extracting repository from work item: {str(e)}") - return '' - - def _process_github_issue(self): - """Process GitHub issue creation""" - try: - self.go_button.config(state='disabled') - self.progress.start() - - # Get current work item - current_item = self.current_work_items[self.current_item_index] - github_info = current_item['github_info'] - - # Get configuration - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '').strip() - - # Get dry run setting from config (most up-to-date value) - dry_run_config = config.get('DRY_RUN', 'false') - is_dry_run = str(dry_run_config).lower() in ('true', '1', 'yes', 'on') - - self.logger.log(f"=== Creating GitHub Issue for {current_item.get('source', 'Azure DevOps')} item #{current_item['id']} ===") - if is_dry_run: - self.logger.log("🧪 DRY RUN MODE ENABLED - No actual changes will be made") - self.update_status("Creating GitHub issue...") - - # Create GitHub API instance - from .github_api import GitHubAPI - from .utils import ContentBuilders - - github_api = GitHubAPI(github_token, self.logger, is_dry_run) - - # Get repository ID - owner = github_info['owner'] - repo = github_info['repo'] - - self.logger.log(f"Target repository: {owner}/{repo}") - repo_id = github_api.get_repo_id(owner, repo) - - # Build issue content - issue_title = ContentBuilders.build_issue_title(current_item) - issue_body = ContentBuilders.build_issue_body(current_item, github_info) - - self.logger.log(f"Creating issue: {issue_title}") - - # Create the issue - issue_id, issue_url, issue_number = github_api.create_issue(repo_id, issue_title, issue_body) - - self.logger.log(f"✅ Issue created successfully: {issue_url}") - self.update_status(f"Issue #{issue_number} created successfully!") - - # Get Copilot actor ID and assign to Copilot if available - copilot_id, copilot_login = github_api.get_copilot_actor_id(owner, repo) - - if copilot_id and issue_id: - github_api.assign_to_copilot(issue_id, [copilot_id]) - self.logger.log("✅ Assigned to Copilot") - else: - self.logger.log("⚠️ Skipped assigning to Copilot (not found)") - - # Update work item status - current_item['status'] = f'Issue #{issue_number} created' - current_item['github_url'] = issue_url - self._update_all_items_tree() - - # Link back to Azure DevOps if applicable (non-critical) - if current_item.get('source') == 'Azure DevOps' and self.azure_api: - try: - link_title = f"GitHub Issue #{issue_number}" - success = self.azure_api.add_github_link_to_work_item( - str(current_item['id']), - issue_url, - link_title - ) - if not success: - self.logger.log("⚠️ Could not link issue back to Azure DevOps work item (non-critical)") - self.logger.log(" Possible causes: PAT expired, insufficient permissions, or work item locked") - self.logger.log(" The issue was created successfully - you can manually link it if needed") - except Exception as e: - self.logger.log(f"⚠️ Could not link issue to Azure DevOps (non-critical): {str(e)}") - self.logger.log(" The issue was created successfully - you can manually link it if needed") - - # Show success dialog with clickable link - HyperlinkDialog( - self.root, - "Issue Created", - f"GitHub Issue #{issue_number} has been created successfully!", - issue_url - ).show() - - except Exception as e: - error_msg = f"Error creating GitHub issue: {str(e)}" - self.logger.log(f"❌ {error_msg}") - self.update_status("Issue creation failed!") - messagebox.showerror("Issue Creation Error", error_msg) - finally: - self.progress.stop() - self.go_button.config(state='normal') - - def _process_github_pr_with_verification(self, target_repo: str, source_repo: str): - """Process GitHub PR creation with verified repositories""" - try: - self.go_button.config(state='disabled') - self.progress.start() - - # Get current work item - current_item = self.current_work_items[self.current_item_index] - github_info = current_item['github_info'] - - self.logger.log(f"=== Creating GitHub PR for {current_item.get('source', 'Azure DevOps')} item #{current_item['id']} ===") - self.logger.log(f"Target Repository: {target_repo}") - self.logger.log(f"Source Repository: {source_repo}") - self.update_status("Creating GitHub PR...") - - # Get configuration - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '').strip() - ai_provider = config.get('AI_PROVIDER', 'none').strip().lower() - - # Get dry run setting from config (most up-to-date value) - dry_run_config = config.get('DRY_RUN', 'false') - is_dry_run = str(dry_run_config).lower() in ('true', '1', 'yes', 'on') - - if is_dry_run: - self.logger.log("🧪 DRY RUN MODE ENABLED - No actual changes will be made") - - # Update config temporarily for this workflow - temp_config = config.copy() - temp_config['GITHUB_REPO'] = target_repo - temp_config['FORKED_REPO'] = source_repo - - # Check if AI provider is configured - if ai_provider and ai_provider not in ['none', '']: - # Use AI-assisted workflow with verified repos - self._process_github_pr_with_ai(current_item, github_info, temp_config) - return - - # Otherwise use Copilot workflow with verified repos - self.logger.log("Using GitHub Copilot workflow with verified repositories") - - # Create GitHub API instance - from .github_api import GitHubAPI - from .utils import ContentBuilders - - github_api = GitHubAPI(github_token, self.logger, is_dry_run) - - # Continue with the standard PR creation but using verified repos - # Parse repository information - if '/' not in target_repo: - raise ValueError("Invalid target repository format") - target_owner, target_repo_name = target_repo.split('/', 1) - - if '/' not in source_repo: - raise ValueError("Invalid source repository format") - source_owner, source_repo_name = source_repo.split('/', 1) - - # Get repository ID for API calls - repository_id = github_api.get_repo_id(target_owner, target_repo_name) - - # Build PR content - builders = ContentBuilders() - pr_title = builders.build_pr_title(current_item) - pr_body = builders.build_pr_body(current_item, github_info) - - # Create branch and PR - from .utils import PRNumberManager - pr_number = PRNumberManager.get_next_pr_number(f"{source_owner}_{source_repo_name}") - branch_name = f"docs-update-{pr_number}" - - # Create branch in source repo - if github_api.create_branch_from_main(source_owner, source_repo_name, branch_name): - self.logger.log(f"✅ Branch '{branch_name}' created in {source_owner}/{source_repo_name}") - - # Create cross-repo PR - pr_url, pr_html_url, pr_num = github_api.create_cross_repo_pull_request( - source_owner, source_repo_name, target_owner, target_repo_name, - branch_name, pr_title, pr_body - ) - - if pr_url: - self.logger.log(f"✅ Pull request created: {pr_html_url}") - - # Add Copilot comment with proper parameters - file_path = github_info.get('file_path', '') - - # Extract file path from GitHub URLs if not already set - if not file_path: - # Try extracting from mydoc_url if it's a GitHub URL - mydoc_url = github_info.get('mydoc_url', '') - if mydoc_url: - extracted_path = self._extract_file_path_from_github_url(mydoc_url) - if extracted_path: - file_path = extracted_path - self.logger.log(f"Extracted file path from GitHub URL: {file_path}") - else: - file_path = f"File path not specified in work item (URL: {mydoc_url})" - else: - file_path = "See work item description for file details" - - # Get custom instructions from config - custom_instructions = config.get('CUSTOM_INSTRUCTIONS', '') - - github_api.add_copilot_comment( - target_owner, target_repo_name, pr_num, - file_path, - current_item.get('text_to_change', ''), - current_item.get('new_text', ''), - branch_name, - str(current_item.get('id', 'unknown')), - current_item.get('source', 'Work Item'), - github_info.get('mydoc_url', ''), - custom_instructions - ) - - # Show success dialog - dialog = HyperlinkDialog( - self.root, - "PR Created Successfully", - f"Pull request #{pr_num} has been created successfully:", - pr_html_url - ) - dialog.show() - - self.update_status(f"PR #{pr_num} created successfully") - else: - messagebox.showerror("Error", "Failed to create pull request") - - except Exception as e: - self.logger.log(f"❌ Error creating GitHub PR: {str(e)}") - messagebox.showerror("Error", f"Failed to create GitHub PR: {str(e)}") - - finally: - self.progress.stop() - self.go_button.config(state='normal') - - def _process_github_pr(self): - """Process GitHub PR creation""" - try: - self.go_button.config(state='disabled') - self.progress.start() - - # Get current work item - current_item = self.current_work_items[self.current_item_index] - github_info = current_item['github_info'] - - self.logger.log(f"=== Creating GitHub PR for {current_item.get('source', 'Azure DevOps')} item #{current_item['id']} ===") - self.update_status("Creating GitHub PR...") - - # Get configuration - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '').strip() - ai_provider = config.get('AI_PROVIDER', 'none').strip().lower() - - # Get dry run setting from config (most up-to-date value) - dry_run_config = config.get('DRY_RUN', 'false') - is_dry_run = str(dry_run_config).lower() in ('true', '1', 'yes', 'on') - - if is_dry_run: - self.logger.log("🧪 DRY RUN MODE ENABLED - No actual changes will be made") - - # Check if AI provider is configured - if ai_provider and ai_provider not in ['none', '']: - # Use AI-assisted workflow - self._process_github_pr_with_ai(current_item, github_info, config) - return - - # Otherwise use Copilot workflow - self.logger.log("Using GitHub Copilot workflow (no AI provider configured)") - - # Create GitHub API instance - from .github_api import GitHubAPI - from .utils import ContentBuilders - - github_api = GitHubAPI(github_token, self.logger, is_dry_run) - - # Get UPSTREAM repository info (where PR will be created) - upstream_repo = config.get('GITHUB_REPO', '').strip() - if not upstream_repo or '/' not in upstream_repo: - raise ValueError("GITHUB_REPO not configured. Set it in Settings (e.g., microsoft/fabric-docs-pr)") - - upstream_parts = upstream_repo.split('/', 1) - upstream_owner = upstream_parts[0].strip() - upstream_repo_name = upstream_parts[1].strip() - - self.logger.log(f"Upstream repository (for PR): {upstream_owner}/{upstream_repo_name}") - - # Get FORK repository info (where branch will be created) - fork_owner = github_info['owner'] - fork_repo = github_info['repo'] - - self.logger.log(f"Fork repository (for branch): {fork_owner}/{fork_repo}") - - # Get upstream repository ID (for creating PR) - upstream_repo_id = github_api.get_repo_id(upstream_owner, upstream_repo_name) - - # Generate unique branch name - pr_number = self.config_manager.get_next_pr_number('gh_copilot') - source_prefix = 'uuf' if current_item.get('source') == 'UUF' else 'ab' - branch_name = f"{source_prefix}-{current_item['id']}-pr-{pr_number}" - - self.logger.log(f"Creating branch on fork: {branch_name}") - - # Extract file path from GitHub URL - file_path = None - if github_info.get('original_content_git_url'): - # Parse file path from URL - import re - url = github_info['original_content_git_url'] - # Match pattern: .../blob/branch/path/to/file.md - match = re.search(r'/blob/[^/]+/(.+)$', url) - if match: - file_path = match.group(1) - self.logger.log(f"Extracted file path: {file_path}") - - # Build PR content - pr_title = ContentBuilders.build_pr_title(current_item) - pr_body = ContentBuilders.build_pr_body(current_item, github_info) - - # Build instructions for placeholder commit - instructions = f"""Update documentation file: {file_path or 'See PR description'} - -Current text to replace: -{current_item['text_to_change']} - -Proposed new text: -{current_item['new_text']} -""" - - # Create branch on FORK with placeholder commit (so PR can be created) - self.logger.log("Creating branch on fork with placeholder commit...") - branch_created = github_api.create_branch_with_placeholder(fork_owner, fork_repo, branch_name, instructions) - - if not branch_created: - raise RuntimeError("Failed to create branch on fork. Check permissions and try again.") - - # Create the PR on UPSTREAM using fork's branch - # For fork workflow: head ref must be "fork-owner:branch-name" - head_ref = f"{fork_owner}:{branch_name}" - self.logger.log(f"Creating pull request on upstream: {pr_title}") - self.logger.log(f"PR head: {head_ref} -> base: main on {upstream_owner}/{upstream_repo_name}") - - _, pr_url, pr_number_actual = github_api.create_pull_request( - upstream_repo_id, pr_title, pr_body, head_ref, "main" - ) - - self.logger.log(f"✅ Pull request created: {pr_url}") - - # Add Copilot comment with instructions (to the fork's branch) - self.logger.log("Adding instructions for Copilot...") - - # Extract file path from GitHub URLs if not already set - if not file_path: - # Try extracting from mydoc_url if it's a GitHub URL - mydoc_url = current_item.get('mydoc_url', '') - if mydoc_url: - extracted_path = self._extract_file_path_from_github_url(mydoc_url) - if extracted_path: - file_path = extracted_path - self.logger.log(f"Extracted file path from GitHub URL: {file_path}") - else: - file_path = f"File path not specified in work item (URL: {mydoc_url})" - else: - file_path = "See work item description for file details" - - # Get custom instructions from config - config = self.config_manager.get_config() - custom_instructions = config.get('CUSTOM_INSTRUCTIONS', '') - - github_api.add_copilot_comment( - fork_owner, fork_repo, pr_number_actual, - file_path, - current_item['text_to_change'], - current_item['new_text'], - branch_name, - str(current_item['id']), - current_item.get('source'), - current_item.get('mydoc_url'), - custom_instructions - ) - - self.logger.log(f"✅ PR #{pr_number_actual} created successfully with Copilot instructions") - self.update_status(f"PR #{pr_number_actual} created successfully!") - - # Update work item status - current_item['status'] = f'PR #{pr_number_actual} created' - current_item['github_url'] = pr_url - self._update_all_items_tree() - - # Link back to Azure DevOps if applicable (non-critical) - if current_item.get('source') == 'Azure DevOps' and self.azure_api: - try: - link_title = f"GitHub PR #{pr_number_actual}" - success = self.azure_api.add_github_link_to_work_item( - str(current_item['id']), - pr_url, - link_title - ) - if not success: - self.logger.log("⚠️ Could not link PR back to Azure DevOps work item (non-critical)") - self.logger.log(" Possible causes: PAT expired, insufficient permissions, or work item locked") - self.logger.log(" The PR was created successfully - you can manually link it if needed") - except Exception as e: - self.logger.log(f"⚠️ Could not link PR to Azure DevOps (non-critical): {str(e)}") - self.logger.log(" The PR was created successfully - you can manually link it if needed") - - # Show success dialog with clickable link - HyperlinkDialog( - self.root, - "Pull Request Created", - f"GitHub PR #{pr_number_actual} has been created successfully!\n\n" - f"Copilot has been instructed to make the requested changes.", - pr_url - ).show() - - except Exception as e: - error_msg = f"Error creating GitHub PR: {str(e)}" - self.logger.log(f"❌ {error_msg}") - self.update_status("PR creation failed!") - messagebox.showerror("PR Creation Error", error_msg) - finally: - self.progress.stop() - self.go_button.config(state='normal') - - def _process_github_pr_with_ai(self, current_item: Dict[str, Any], github_info: Dict[str, Any], config: Dict[str, Any]): - """Process GitHub PR creation using AI provider (ChatGPT/Claude)""" - try: - self.logger.log("=== Using AI-Assisted PR Creation ===") - - # Get AI configuration - ai_provider = config.get('AI_PROVIDER', '').strip().lower() - if ai_provider == 'claude': - api_key = config.get('CLAUDE_API_KEY', '').strip() - elif ai_provider in ['chatgpt', 'openai', 'gpt']: - api_key = config.get('OPENAI_API_KEY', '').strip() - elif ai_provider in ['github-copilot', 'copilot', 'github_copilot']: - api_key = config.get('GITHUB_TOKEN', '').strip() - elif ai_provider == 'ollama': - api_key = config.get('OLLAMA_API_KEY', '').strip() # Optional for Ollama - else: - api_key = '' - github_token = config.get('GITHUB_PAT', '').strip() - local_repo_path = config.get('LOCAL_REPO_PATH', '').strip() or None - - # Validate API key (except for Ollama where it's optional) - if not api_key and ai_provider != 'ollama': - raise ValueError(f"No API key configured for {ai_provider}. Please configure in Settings.") - - # Get Ollama-specific configuration - ollama_url = None - ollama_model = None - if ai_provider == 'ollama': - ollama_url = config.get('OLLAMA_URL', '').strip() - ollama_model = config.get('OLLAMA_MODEL', '').strip() - if not ollama_url: - raise ValueError("Ollama Server URL not configured. Please configure in Settings.") - if not ollama_model: - raise ValueError("Ollama Model not selected. Please configure in Settings.") - - self.logger.log(f"Using AI Provider: {ai_provider.upper()}") - - # Create AI manager - from .ai_manager import AIManager - ai_manager = AIManager(self.logger) - - # Create AI provider instance - ai_provider_instance = ai_manager.create_ai_provider(ai_provider, api_key, ollama_url, ollama_model) - if not ai_provider_instance: - raise ValueError(f"Failed to create {ai_provider} provider") - - # Create LocalGitManager - git_manager = ai_manager.create_local_git_manager(github_token) - if not git_manager: - raise ValueError("Failed to create git manager") - - # Get UPSTREAM repository info (where PR will be created) - upstream_repo = config.get('GITHUB_REPO', '').strip() - if not upstream_repo or '/' not in upstream_repo: - raise ValueError("GITHUB_REPO not configured. Set it in Settings (e.g., microsoft/fabric-docs-pr)") - - upstream_parts = upstream_repo.split('/', 1) - upstream_owner = upstream_parts[0].strip() - upstream_repo_name = upstream_parts[1].strip() - - self.logger.log(f"Upstream repository (for PR): {upstream_owner}/{upstream_repo_name}") - - # Get FORK repository info (where we work locally) - # Use github_info from document metadata as the fork - fork_owner = github_info['owner'] - fork_repo = github_info['repo'] - - self.logger.log(f"Fork repository (local work): {fork_owner}/{fork_repo}") - self.logger.log(f"Local repository base path: {local_repo_path}") - - # Extract file path from GitHub URL - file_path = None - if github_info.get('original_content_git_url'): - import re - url = github_info['original_content_git_url'] - match = re.search(r'/blob/[^/]+/(.+)$', url) - if match: - file_path = match.group(1) - self.logger.log(f"File to modify: {file_path}") - - if not file_path: - raise ValueError("Could not extract file path from document URL") - - # Generate unique branch name - pr_number = self.config_manager.get_next_pr_number(ai_provider) - source_prefix = 'uuf' if current_item.get('source') == 'UUF' else 'ab' - branch_name = f"{source_prefix}-{current_item['id']}-{ai_provider}-pr-{pr_number}" - - self.logger.log(f"Branch name: {branch_name}") - - # Build commit message - commit_message = f"Update {file_path}\n\nWork Item: {current_item['id']}\nTitle: {current_item['title']}" - - # Get custom instructions from config - custom_instructions = config.get('CUSTOM_INSTRUCTIONS', '').strip() or None - - # Make AI-assisted changes on FORK - self.logger.log("Starting AI-assisted workflow on fork...") - success, error_msg = git_manager.make_ai_assisted_change( - fork_owner, fork_repo, branch_name, - file_path, - current_item['text_to_change'], - current_item['new_text'], - commit_message, - ai_provider_instance, - local_repo_path, - custom_instructions - ) - - if not success: - raise RuntimeError(error_msg or "AI-assisted change failed") - - # Update the diff display with the actual git diff - try: - # Construct the full repository path for git diff - if local_repo_path: - full_repo_path = os.path.join(local_repo_path, fork_owner, fork_repo) - else: - # Fallback to default Downloads location - from pathlib import Path - full_repo_path = str(Path.home() / "Downloads" / "github_repos" / fork_owner / fork_repo) - - diff_content = git_manager.get_git_diff_from_repo(full_repo_path, branch_name) - if diff_content: - self.update_diff_display(diff_content) - self.logger.log("📋 Git diff content updated in View Diff tab") - else: - self.logger.log("⚠️ No git diff content found") - except Exception as e: - self.logger.log(f"⚠️ Could not update diff display: {e}") - - # Create PR on UPSTREAM repository - from .github_api import GitHubAPI - from .utils import ContentBuilders - - self.logger.log(f"Creating PR on upstream: {upstream_owner}/{upstream_repo_name}") - github_api = GitHubAPI(github_token, self.logger, False) - repo_id = github_api.get_repo_id(upstream_owner, upstream_repo_name) - - pr_title = ContentBuilders.build_pr_title(current_item) - pr_body = ContentBuilders.build_pr_body(current_item, github_info) - pr_body += f"\n\n---\n*Changes made by {ai_provider.upper()} via AI-assisted workflow*" - - # For fork workflow: head ref must be "fork-owner:branch-name" - head_ref = f"{fork_owner}:{branch_name}" - self.logger.log(f"Creating pull request: {pr_title}") - self.logger.log(f"PR head: {head_ref} -> base: main on {upstream_owner}/{upstream_repo_name}") - - _, pr_url, pr_number_actual = github_api.create_pull_request( - repo_id, pr_title, pr_body, head_ref, "main" - ) - - self.logger.log(f"✅ PR #{pr_number_actual} created successfully with AI-generated changes") - self.update_status(f"PR #{pr_number_actual} created successfully!") - - # Update work item status - current_item['status'] = f'PR #{pr_number_actual} created ({ai_provider.upper()})' - current_item['github_url'] = pr_url - self._update_all_items_tree() - - # Link back to Azure DevOps if applicable (non-critical) - if current_item.get('source') == 'Azure DevOps' and self.azure_api: - try: - link_title = f"GitHub PR #{pr_number_actual}" - success = self.azure_api.add_github_link_to_work_item( - str(current_item['id']), - pr_url, - link_title - ) - if not success: - self.logger.log("⚠️ Could not link PR back to Azure DevOps work item (non-critical)") - except Exception as e: - self.logger.log(f"⚠️ Could not link PR to Azure DevOps (non-critical): {str(e)}") - - # Show success dialog - HyperlinkDialog( - self.root, - "Pull Request Created", - f"GitHub PR #{pr_number_actual} has been created successfully!\n\n" - f"{ai_provider.upper()} has made the requested changes and pushed them to the branch.", - pr_url - ).show() - - except Exception as e: - error_msg = f"Error creating AI-assisted PR: {str(e)}" - self.logger.log(f"❌ {error_msg}") - self.update_status("AI-assisted PR creation failed!") - messagebox.showerror("PR Creation Error", error_msg) - finally: - self.progress.stop() - self.go_button.config(state='normal') - - def next_item(self): - """Navigate to next work item""" - if self.current_item_index < len(self.current_work_items) - 1: - self.current_item_index += 1 - self._display_current_item() - self._update_navigation_buttons() - - def previous_item(self): - """Navigate to previous work item""" - if self.current_item_index > 0: - self.current_item_index -= 1 - self._display_current_item() - self._update_navigation_buttons() - - def _on_item_select(self, event): - """Handle item selection in the All Work Items treeview""" - selection = self.items_tree.selection() - if selection: - self.selected_tree_item = selection[0] - self.select_item_button.config(state='normal') - else: - self.selected_tree_item = None - self.select_item_button.config(state='disabled') - - def _on_item_double_click(self, event): - """Handle double-click on item in the All Work Items treeview""" - selection = self.items_tree.selection() - if selection: - self.selected_tree_item = selection[0] - self._select_current_item() - - def _select_current_item(self): - """Select the highlighted item from the treeview as the current work item""" - if not self.selected_tree_item: - return - - try: - # Get the work item ID from the selected tree item - item_values = self.items_tree.item(self.selected_tree_item, 'values') - if not item_values: - return - - selected_work_item_id = item_values[0] # ID is in the first column - - # Debug logging - self.logger.log(f"Looking for work item ID: {selected_work_item_id} (type: {type(selected_work_item_id)})") - self.logger.log(f"Available work items: {len(self.current_work_items)}") - if self.current_work_items: - self.logger.log(f"Sample work item ID: {self.current_work_items[0]['id']} (type: {type(self.current_work_items[0]['id'])})") - - # Find the work item in the current_work_items list (which contains all loaded items) - if not self.current_work_items: - messagebox.showwarning("No Work Items", "No work items are loaded.") - return - - selected_work_item = None - for work_item in self.current_work_items: - # Convert both IDs to strings for comparison to handle type mismatches - if str(work_item['id']) == str(selected_work_item_id): - selected_work_item = work_item - break - - if not selected_work_item: - messagebox.showerror("Item Not Found", - f"Work item #{selected_work_item_id} was not found in the loaded work items.") - return - - # Find the index of the selected work item in the current list - selected_index = -1 - for i, work_item in enumerate(self.current_work_items): - if str(work_item['id']) == str(selected_work_item_id): - selected_index = i - break - - if selected_index == -1: - messagebox.showerror("Item Not Found", - f"Work item #{selected_work_item_id} was not found in the loaded work items.") - return - - # Set the current item index to the selected item (keeping the full list intact) - self.current_item_index = selected_index - - # Update the display - self._display_current_item() - self._update_navigation_buttons() - - # Switch to the main work item tab to show the selected item - self.notebook.select(0) # Select the first tab (main work item tab) - - # Log the selection - self.logger.log(f"📌 Selected work item #{selected_work_item_id} as current item") - self.logger.log(f"Title: {selected_work_item['title']}") - - except Exception as e: - self.logger.log(f"❌ Error selecting work item: {e}") - messagebox.showerror("Error", f"Failed to select work item:\n{str(e)}") - - def create_github_resource(self): - """Create GitHub issue or PR for current work item""" - return self._create_github_resource() - - def start_fetch_work_items(self): - """Start fetching work items""" - return self._start_fetch_work_items() - - def start_fetch_uuf_items(self): - """Start fetching UUF items""" - return self._start_fetch_uuf_items() - - def toggle_edit_mode(self): - """Toggle edit mode for the Proposed New Text field""" - return self._toggle_edit_mode() - - def on_work_item_hover_enter(self, event=None): - """Handle mouse enter on work item ID label""" - return self._on_work_item_hover_enter(event) - - def on_work_item_hover_leave(self, event=None): - """Handle mouse leave on work item ID label""" - return self._on_work_item_hover_leave(event) - - def open_work_item_url(self, event=None): - """Open the Azure DevOps work item URL in the browser""" - return self._open_work_item_url(event) - - def check_ai_modules_manual(self): - """Manually check AI modules""" - return self._check_ai_modules_manual() - - def open_settings(self): - """Open settings dialog""" - return self._open_settings() - - def update_action_button_text(self): - """Update action button text based on dropdown selection""" - action_type = self.action_type_var.get() - if action_type == "Create PR": - self.go_button.config(text="🚀 Create PR") - else: - self.go_button.config(text="🚀 Create Issue") - - def check_ai_provider_setup(self): - """Check AI provider setup and offer to install missing modules""" + async def _load_custom_instructions(self): + """Load custom instructions from config""" try: config = self.config_manager.get_config() - ai_provider = config.get('AI_PROVIDER', '').strip().lower() - - if not ai_provider or ai_provider == 'none' or ai_provider == '': - return # No AI provider selected - - # Check if this provider requires special modules - if ai_provider not in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot']: - return # Unknown provider, skip check - - # Check module availability using AI manager - self.ai_manager.check_and_install_ai_modules(ai_provider, self.root) + 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: - self.logger.log(f"Error checking AI provider setup: {str(e)}") + print(f"Error loading custom instructions: {e}") - # ===== GitHub Tools Methods ===== + async def _init_load_repos(self): + """Initialize repository loading""" + await self._load_target_repos_async() + await self._load_forked_repos_async() - def _init_load_repos(self): - """Initialize loading of repositories""" - self._load_target_repos_async() - self._load_forked_repos_async() - - def _load_target_repos_async(self): - """Load target repositories asynchronously""" + async def _load_target_repos_async(self): + """Load target repositories""" def load_repos(): try: - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '') + github_token = self.config_manager.get_config().get('GITHUB_PAT', '') if not github_token: return @@ -2340,692 +914,374 @@ Proposed new text: repos = repo_fetcher.fetch_repos_with_permissions(min_permission='push') self.target_repos = repo_fetcher.get_repo_names(repos) - # Update dropdown on main thread - self.root.after(0, self._update_target_dropdown) + # Update UI + if self.target_repo_dropdown_ref.current: + self.page.run_task(self._update_target_dropdown_async) except Exception as e: - self.logger.log(f"Error loading target repos: {e}") + if self.logger: + self.logger.log(f"Error loading target repos: {e}") - threading.Thread(target=load_repos, daemon=True).start() + await asyncio.to_thread(load_repos) - def _update_target_dropdown(self): - """Update the target repository dropdown""" - try: - current_values = [''] # Start with empty option - - # Add user's repos with edit access - if self.target_repos: - current_values.append('--- Your Repos (with edit access) ---') - current_values.extend(self.target_repos) - - self.target_repo_dropdown['values'] = current_values - - except Exception as e: - self.logger.log(f"Error updating target dropdown: {e}") - - def _refresh_target_repos(self): - """Refresh target repositories""" - self._load_target_repos_async() - - def _search_target_repos(self): - """Search for repositories on GitHub""" - query = self.target_repo_var.get().strip() - if not query: + async def _update_target_dropdown_async(self): + """Update target repository dropdown""" + if not self.target_repo_dropdown_ref.current: return - def search_repos(): - try: - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '') - if not github_token: - 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]) - from .workflow import GitHubRepoFetcher - repo_fetcher = GitHubRepoFetcher(github_token, self.logger) - repos = repo_fetcher.search_repositories(query, per_page=50) - search_results = repo_fetcher.get_repo_names(repos) + self.target_repo_dropdown_ref.current.options = options - # Update dropdown on main thread - self.root.after(0, lambda: self._update_target_search_results(search_results)) + # 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 - except Exception as e: - self.logger.log(f"Error searching repos: {e}") + self.page.update() - threading.Thread(target=search_repos, daemon=True).start() + async def _refresh_target_repos_async(self): + """Refresh target repositories""" + await self._load_target_repos_async() - def _update_target_search_results(self, search_results): - """Update target dropdown with search results""" - try: - current_values = [''] + async def _search_target_repos_async(self): + """Search for repositories on GitHub""" + # Implementation would search GitHub repos + pass - # Add user's repos - if self.target_repos: - current_values.append('--- Your Repos (with edit access) ---') - current_values.extend(self.target_repos) - - # Add search results - if search_results: - current_values.append('--- Search Results ---') - current_values.extend(search_results) - - self.target_repo_dropdown['values'] = current_values - - except Exception as e: - self.logger.log(f"Error updating search results: {e}") - - def _on_target_repo_search(self, _event): - """Handle typing in target repo field for auto-search""" - # Debounce: only search after user stops typing for 500ms - if hasattr(self, '_search_timer'): - self.root.after_cancel(self._search_timer) - - query = self.target_repo_var.get().strip() - if len(query) >= 3: # Only search if at least 3 characters - self._search_timer = self.root.after(500, self._search_target_repos) - - def _load_forked_repos_async(self): - """Load forked repositories asynchronously""" + async def _load_forked_repos_async(self): + """Load forked repositories""" def load_forks(): try: - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '') - local_repo_path = config.get('LOCAL_REPO_PATH', '') - # Load local repos - local_repos = [] + local_repo_path = self.config_manager.get_config().get('LOCAL_REPO_PATH', '') if local_repo_path: try: from .utils import LocalRepositoryScanner - local_repos = LocalRepositoryScanner.scan_local_repos(local_repo_path) + self.forked_repos['local'] = LocalRepositoryScanner.scan_local_repos(local_repo_path) except Exception as e: - self.logger.log(f"Error scanning local repos: {e}") + print(f"Error scanning local repos: {e}") # Load GitHub repos - github_repos = [] + github_token = self.config_manager.get_config().get('GITHUB_PAT', '') if github_token: - try: - from .workflow import GitHubRepoFetcher - repo_fetcher = GitHubRepoFetcher(github_token, self.logger) - repos = repo_fetcher.fetch_user_repos(repo_type='owner') - github_repos = repo_fetcher.get_repo_names(repos) - except Exception as e: - self.logger.log(f"Error loading GitHub repos: {e}") + 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) - self.forked_repos = {'local': local_repos, 'github': github_repos} - - # Update dropdown on main thread - self.root.after(0, self._update_forked_dropdown) + # Update UI + if self.forked_repo_dropdown_ref.current: + self.page.run_task(self._update_forked_dropdown_async) except Exception as e: - self.logger.log(f"Error loading forked repos: {e}") + if self.logger: + self.logger.log(f"Error loading forked repos: {e}") - threading.Thread(target=load_forks, daemon=True).start() + await asyncio.to_thread(load_forks) - def _update_forked_dropdown(self): - """Update the forked repository dropdown""" - try: - current_values = [''] # Start with empty option + async def _update_forked_dropdown_async(self): + """Update forked repository dropdown""" + if not self.forked_repo_dropdown_ref.current: + return - # Add local repos - if self.forked_repos.get('local'): - current_values.append('--- Local Repositories ---') - current_values.extend(self.forked_repos['local']) + options = [] - # Add GitHub repos - if self.forked_repos.get('github'): - current_values.append('--- Your GitHub Forks ---') - current_values.extend(self.forked_repos['github']) + # 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']]) - self.forked_repo_dropdown['values'] = current_values + # 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']]) - except Exception as e: - self.logger.log(f"Error updating forked dropdown: {e}") + self.forked_repo_dropdown_ref.current.options = options - def _refresh_forked_repos(self): + # 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""" - self._load_forked_repos_async() + await self._load_forked_repos_async() - def _clone_forked_repo(self): - """Clone the selected forked repository""" - selected_repo = self.forked_repo_var.get().strip() + def _clone_forked_repo(self, e): + """Clone forked repository""" + # Implementation would clone the selected repo + pass - # Validate selection - if not selected_repo: - messagebox.showwarning("No Repository Selected", - "Please select a repository to clone.") - return - - # Check if it's a section header - if selected_repo.startswith('---'): - messagebox.showwarning("Invalid Selection", - "Please select a repository, not a section header.") - return - - config = self.config_manager.get_config() - local_repo_path = config.get('LOCAL_REPO_PATH', '').strip() - if not local_repo_path: - messagebox.showwarning("Local Path Not Configured", - "Please configure the Local Repository Path in settings first.") - return - - # Clone logic (similar to settings_dialog.py) - import subprocess - import os - - try: - os.makedirs(local_repo_path, exist_ok=True) - except Exception as e: - messagebox.showerror("Directory Error", - f"Could not create local repository directory:\n{str(e)}") - return - - # Extract repo name - repo_name = selected_repo - if '/' not in repo_name: - messagebox.showerror("Invalid Repository", - "Repository must be in 'owner/repo' format.") - return - - folder_name = repo_name.split('/')[-1] - target_path = os.path.join(local_repo_path, folder_name) - - if os.path.exists(target_path): - response = messagebox.askyesno("Directory Exists", - f"The directory '{folder_name}' already exists.\n\n" - f"Do you want to continue anyway?") - if not response: - return - - clone_url = f"https://github.com/{repo_name}.git" - - def clone_repo(): - try: - result = subprocess.run( - ['git', 'clone', clone_url, target_path], - capture_output=True, - text=True, - timeout=300 - ) - - if result.returncode == 0: - self.root.after(0, lambda: messagebox.showinfo( - "Clone Successful", - f"Successfully cloned {repo_name}!")) - self.root.after(0, self._refresh_forked_repos) - else: - error_msg = result.stderr if result.stderr else result.stdout - self.root.after(0, lambda: messagebox.showerror( - "Clone Failed", - f"Failed to clone {repo_name}.\n\n{error_msg}")) - - except subprocess.TimeoutExpired: - self.root.after(0, lambda: messagebox.showerror( - "Clone Timeout", - f"Cloning {repo_name} timed out after 5 minutes.")) - except FileNotFoundError: - self.root.after(0, lambda: messagebox.showerror( - "Git Not Found", - "Git is not installed or not found in PATH.")) - except Exception as e: - self.root.after(0, lambda: messagebox.showerror( - "Clone Error", - f"An error occurred while cloning:\n{str(e)}")) - - messagebox.showinfo("Cloning Repository", - f"Cloning {repo_name} to:\n{target_path}\n\n" - f"This may take a few moments...") - - threading.Thread(target=clone_repo, daemon=True).start() - - def _on_mode_changed(self): - """Handle mode change between Create and Action""" - mode = self.tools_mode_var.get() - - if mode == "action": - # Show action mode widgets - for widget in self.action_mode_widgets: - widget.grid() - - # Hide create mode widgets - for widget in self.create_mode_widgets: - widget.grid_remove() - else: # create mode - # Hide action mode widgets - for widget in self.action_mode_widgets: - widget.grid_remove() - - # Show create mode widgets - for widget in self.create_mode_widgets: - widget.grid() - - def _create_new_item(self): - """Handle creating a new PR or Issue""" - create_type = self.create_type_var.get() - target_repo = self.target_repo_var.get().strip() - - # Skip section headers - if target_repo.startswith('---'): - target_repo = '' - - if not target_repo: - messagebox.showwarning("No Repository Selected", - "Please select a target repository.") - return - - if create_type == "pull_request": - # TODO: Implement PR creation workflow - messagebox.showinfo("Create Pull Request", - f"PR creation workflow for {target_repo} will be implemented here.\n\n" - "This will open the PR creation interface in the tabs below.") - else: # issue - # TODO: Implement Issue creation workflow - messagebox.showinfo("Create Issue", - f"Issue creation workflow for {target_repo} will be implemented here.\n\n" - "This will open the Issue creation interface in the tabs below.") - - def _on_repo_selection_changed(self): - """Handle repository selection change""" - # Clear workflow items when repos change - self.workflow_items = [] - self.current_workflow_items = [] - self.workflow_item_dropdown['values'] = [''] - self.workflow_item_var.set('') - self.item_counter_label.config(text="No items loaded") - - def _load_workflow_items(self): - """Load workflow items from selected repositories""" - target_repo = self.target_repo_var.get().strip() - forked_repo = self.forked_repo_var.get().strip() - - # Skip section headers - if target_repo.startswith('---'): - target_repo = '' - if forked_repo.startswith('---'): - forked_repo = '' - - if not target_repo and not forked_repo: - messagebox.showwarning("No Repositories Selected", - "Please select at least one repository.") - return - - self.progress.start() - self.update_status("Loading workflow items...") + async def _load_workflow_items_async(self): + """Load workflow items (PRs/Issues)""" + 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: - config = self.config_manager.get_config() - github_token = config.get('GITHUB_PAT', '') + 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) - # Fetch all items - results = workflow_manager.fetch_all_workflow_items( - target_repo=target_repo if target_repo else None, - fork_repo=forked_repo if forked_repo else None, - include_issues=True, - include_prs=True, - state='open' # Only load open items - ) + # 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'}") - self.workflow_items = results + # 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}") - # Update UI on main thread - self.root.after(0, self._on_workflow_items_loaded) + print(f"Calling workflow_manager.fetch_pull_requests('{target_repo}')...") + self.workflow_items['target_prs'] = workflow_manager.fetch_pull_requests(target_repo) + print(f"Calling workflow_manager.fetch_issues('{target_repo}')...") + self.workflow_items['target_issues'] = workflow_manager.fetch_issues(target_repo) + + 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}") + self.workflow_items['fork_prs'] = workflow_manager.fetch_pull_requests(forked_repo) + self.workflow_items['fork_issues'] = workflow_manager.fetch_issues(forked_repo) + 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) except Exception as e: - self.logger.log(f"Error loading workflow items: {e}") - self.root.after(0, lambda: self.update_status("Failed to load workflow items")) - self.root.after(0, lambda: messagebox.showerror( - "Load Error", - f"Failed to load workflow items:\n{str(e)}")) - finally: - self.root.after(0, self.progress.stop) + if self.logger: + self.logger.log(f"Error loading workflow items: {e}") + import traceback + self.logger.log(traceback.format_exc()) - threading.Thread(target=load_items, daemon=True).start() + await asyncio.to_thread(load_items) - def _on_workflow_items_loaded(self): - """Handle workflow items loaded""" - total_items = sum(len(items) for items in self.workflow_items.values()) - self.logger.log(f"Loaded {total_items} workflow items") - self.update_status(f"Loaded {total_items} workflow items") - - # Apply current filters + async def _filter_workflow_items_async(self): + """Filter workflow items async""" self._filter_workflow_items() - def _filter_workflow_items(self): - """Filter workflow items based on current selections""" - if not self.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 - repo_source = self.repo_source_var.get() # 'target' or 'fork' - item_type = self.item_type_var.get() # 'pull_request' or 'issue' + item = self.current_work_items[self.current_item_index] - # Get the appropriate list - # WorkflowManager returns keys like: 'target_prs', 'target_issues', 'fork_prs', 'fork_issues' - key = f"{repo_source}_prs" if item_type == 'pull_request' else f"{repo_source}_issues" - filtered_items = self.workflow_items.get(key, []) + # 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')}" - # Update dropdown - self.current_workflow_items = filtered_items - item_options = [ - f"#{item.number} - {item.title}" for item in filtered_items - ] + if self.nature_text_ref.current: + self.nature_text_ref.current.value = item.get('nature', '') - self.workflow_item_dropdown['values'] = item_options if item_options else [''] - self.workflow_item_var.set('') + if self.live_doc_url_ref.current: + self.live_doc_url_ref.current.value = item.get('live_doc_url', '') - # Update counter - count = len(filtered_items) - source_name = "Target" if repo_source == "target" else "Fork" - type_name = "PRs" if item_type == "pull_request" else "Issues" - self.item_counter_label.config(text=f"{count} {source_name} {type_name}") + if self.text_to_change_ref.current: + self.text_to_change_ref.current.value = item.get('old_text', '') - def _on_workflow_item_selected(self, _event): - """Handle workflow item selection""" - selected = self.workflow_item_var.get() - if not selected: - return + if self.proposed_new_text_ref.current: + self.proposed_new_text_ref.current.value = item.get('new_text', '') - # Extract item number from selection + self.page.update() + self._update_navigation_buttons() + + def _update_navigation_buttons(self): + """Update navigation button states""" + if self.prev_button_ref.current: + self.prev_button_ref.current.disabled = (self.current_item_index == 0) + + if self.next_button_ref.current: + self.next_button_ref.current.disabled = ( + self.current_item_index >= len(self.current_work_items) - 1 + ) + + self.page.update() + + 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: - item_number = int(selected.split('#')[1].split(' ')[0]) + print("Settings button clicked!") - # Find the item - for item in self.current_workflow_items: - if item.number == item_number: - self._display_workflow_item(item) - break + # 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'}") - except Exception as e: - self.logger.log(f"Error selecting workflow item: {e}") + settings_dialog = SettingsDialog( + self.page, + config, + self.config_manager, + self.cache_manager + ) + print("SettingsDialog created") - def _display_workflow_item(self, item): - """Display workflow item details""" - # Update Current Work Item tab - self.work_item_id_label.config(text=f"{item.item_type.upper()} #{item.number}") + def on_settings_result(result): + if result: + # Reload configuration + self.config_manager.load_configuration() + self._show_snackbar("Settings saved successfully!") - # Update nature text - self.nature_text.config(state='normal') - self.nature_text.delete('1.0', tk.END) - self.nature_text.insert('1.0', item.title) - self.nature_text.config(state='disabled') + print("Calling settings_dialog.show()...") + settings_dialog.show(on_result=on_settings_result) + print("settings_dialog.show() completed") - # Update URL - self.doc_url_text.config(state='normal') - self.doc_url_text.delete('1.0', tk.END) - self.doc_url_text.insert('1.0', item.url) - self.doc_url_text.config(state='disabled') + 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) - # Update description - self.description_text.config(state='normal') - self.description_text.delete('1.0', tk.END) - self.description_text.insert('1.0', item.body or 'No description') - self.description_text.config(state='disabled') - - self.logger.log(f"Displaying {item.item_type} #{item.number}: {item.title}") - - def display_current_item(self): - """Display current work item (public method for compatibility)""" - return self._display_current_item() - - def update_navigation_buttons(self): - """Update navigation button states (public method for compatibility)""" - return self._update_navigation_buttons() - - def update_all_items_tree(self): - """Update all items tree (public method for compatibility)""" - return self._update_all_items_tree() - - def process_github_issue(self): - """Process GitHub issue creation (public method for compatibility)""" - return self._process_github_issue() - - def process_github_pr(self): - """Process GitHub PR creation (public method for compatibility)""" - return self._process_github_pr() - - def update_diff_display(self, diff_content): - """Update the diff display with AI-generated patch content""" + def _show_real_settings(self): + """Show the real settings dialog""" try: - self.diff_text.config(state='normal') - self.diff_text.delete('1.0', tk.END) - - if not diff_content or diff_content.strip() == "": - self.diff_text.insert(tk.END, "No diff content available yet.\nDiffs will be generated from git changes or you can load existing .diff files using the 'Find .diff Files' button.") - self.diff_text.config(state='disabled') - self.clear_diff_button.config(state='disabled') - return - - # Clean and validate diff content - diff_content = self._clean_diff_content(diff_content) - - # Parse and highlight diff content - lines = diff_content.split('\n') - for line in lines: - if line.startswith('---') or line.startswith('+++'): - self.diff_text.insert(tk.END, line + '\n', 'diff_file') - elif line.startswith('@@'): - self.diff_text.insert(tk.END, line + '\n', 'diff_line_numbers') - elif line.startswith('+') and not line.startswith('+++'): - self.diff_text.insert(tk.END, line + '\n', 'diff_add') - elif line.startswith('-') and not line.startswith('---'): - self.diff_text.insert(tk.END, line + '\n', 'diff_remove') - elif line.startswith('diff ') or line.startswith('index '): - self.diff_text.insert(tk.END, line + '\n', 'diff_header') - else: - self.diff_text.insert(tk.END, line + '\n', 'diff_context') - - self.diff_text.config(state='disabled') - self.clear_diff_button.config(state='normal') - - # Log the diff update - self.logger.log("✅ Diff content displayed in View Diff tab") - - except Exception as e: - self.logger.log(f"❌ Error updating diff display: {e}") - - def clear_diff_display(self): - """Clear the diff display""" - try: - self.diff_text.config(state='normal') - self.diff_text.delete('1.0', tk.END) - self.diff_text.insert(tk.END, "Diff cleared.\nUse 'Find .diff Files' button to load existing diff files from local repositories.") - self.diff_text.config(state='disabled') - self.clear_diff_button.config(state='disabled') - self.logger.log("🧹 Diff display cleared") - except Exception as e: - self.logger.log(f"❌ Error clearing diff display: {e}") + config = self.config_manager.get_config() + print(f"Got config: {config.keys() if config else 'None'}") - def find_and_load_diff_files(self): - """Find and load existing .diff files from local repositories""" - try: - import os - import glob - from tkinter import messagebox - from pathlib import Path - - # Get local repo path from settings - local_repo_path = self.config_manager.get('LOCAL_REPO_PATH', '').strip() - if not local_repo_path or not os.path.exists(local_repo_path): - self.logger.log("⚠️ No local repo path configured or path doesn't exist") - messagebox.showwarning("No Local Repo Path", - "Please configure LOCAL_REPO_PATH in Settings to find diff files.") - return - - base_path = Path(local_repo_path) - diff_files = [] - - # First, try to find detected repositories (owner/repo structure) - detected_repos = [] - try: - # Look for owner/repo structure: base_path/owner/repo/.git - for owner_dir in base_path.iterdir(): - if not owner_dir.is_dir(): - continue - for repo_dir in owner_dir.iterdir(): - if not repo_dir.is_dir(): - continue - # Check if it's a git repo - git_dir = repo_dir / ".git" - if git_dir.exists(): - detected_repos.append(repo_dir) - self.logger.log(f"🔍 Scanning for diff files in: {owner_dir.name}/{repo_dir.name}") - except Exception as e: - self.logger.log(f"⚠️ Error scanning for repositories: {e}") - - # Search for .diff files in detected repositories first - if detected_repos: - for repo_path in detected_repos: - for root, dirs, files in os.walk(repo_path): - for file in files: - if file.endswith('.diff'): - full_path = os.path.join(root, file) - relative_path = os.path.relpath(full_path, local_repo_path) - diff_files.append((relative_path, full_path)) - - # If no diff files found in detected repos, fallback to searching entire base path - if not diff_files: - self.logger.log("🔍 No diff files found in detected repositories, searching entire base path...") - for root, dirs, files in os.walk(local_repo_path): - for file in files: - if file.endswith('.diff'): - full_path = os.path.join(root, file) - relative_path = os.path.relpath(full_path, local_repo_path) - diff_files.append((relative_path, full_path)) - - if not diff_files: - self.logger.log("ℹ️ No .diff files found in local repositories") - messagebox.showinfo("No Diff Files Found", - f"No .diff files found in {local_repo_path}\n\nSearched in:\n" + - "\n".join([f" • {repo.parent.name}/{repo.name}" for repo in detected_repos]) if detected_repos else f" • {local_repo_path}") - return - - self.logger.log(f"📁 Found {len(diff_files)} diff file(s)") - - # If only one diff file, load it directly - if len(diff_files) == 1: - file_path = diff_files[0][1] - self._load_diff_file(file_path) - return - - # If multiple files, show selection dialog - self._show_diff_file_selection(diff_files) - - except Exception as e: - self.logger.log(f"❌ Error finding diff files: {e}") - messagebox.showerror("Error", f"Error finding diff files: {e}") + settings_dialog = SettingsDialog( + self.page, + config, + self.config_manager, + self.cache_manager + ) + print("SettingsDialog created") - def _show_diff_file_selection(self, diff_files): - """Show dialog to select which diff file to load""" - try: - import tkinter as tk - from tkinter import ttk, messagebox - - # Create selection dialog - selection_window = tk.Toplevel(self.root) - selection_window.title("Select Diff File") - selection_window.geometry("600x400") - selection_window.transient(self.root) - selection_window.grab_set() - - # Center the window - selection_window.geometry("+%d+%d" % - (self.root.winfo_rootx() + 50, self.root.winfo_rooty() + 50)) - - frame = ttk.Frame(selection_window) - frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - - # Title - ttk.Label(frame, text="Select a .diff file to view:", - font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 10)) - - # Listbox with scrollbar - listbox_frame = ttk.Frame(frame) - listbox_frame.pack(fill=tk.BOTH, expand=True) - - scrollbar = ttk.Scrollbar(listbox_frame) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - listbox = tk.Listbox(listbox_frame, yscrollcommand=scrollbar.set, - font=('Courier New', 9)) - listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.config(command=listbox.yview) - - # Populate listbox - for relative_path, full_path in diff_files: - listbox.insert(tk.END, relative_path) - - # Buttons - button_frame = ttk.Frame(frame) - button_frame.pack(fill=tk.X, pady=(10, 0)) - - def load_selected(): - selection = listbox.curselection() - if selection: - selected_file = diff_files[selection[0]][1] - selection_window.destroy() - self._load_diff_file(selected_file) - else: - messagebox.showwarning("No Selection", "Please select a diff file to load.") - - ttk.Button(button_frame, text="Load Selected", command=load_selected).pack(side=tk.LEFT) - ttk.Button(button_frame, text="Cancel", - command=selection_window.destroy).pack(side=tk.LEFT, padx=(10, 0)) - - # Double-click to load - listbox.bind('', lambda e: load_selected()) - - except Exception as e: - self.logger.log(f"❌ Error showing diff file selection: {e}") + def on_settings_result(result): + if result: + # Reload configuration + self.config_manager.load_configuration() + self._show_snackbar("Settings saved successfully!") - def _load_diff_file(self, file_path): - """Load and display a specific diff file""" + 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: - with open(file_path, 'r', encoding='utf-8') as f: - diff_content = f.read() - - if diff_content.strip(): - self.update_diff_display(diff_content) - self.logger.log(f"✅ Loaded diff file: {os.path.basename(file_path)}") + 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.logger.log(f"⚠️ Diff file is empty: {file_path}") - + self._show_snackbar( + f"AI Provider ({ai_provider}): Missing packages: {', '.join(missing)}", + error=True + ) except Exception as e: - self.logger.log(f"❌ Error loading diff file {file_path}: {e}") - from tkinter import messagebox - messagebox.showerror("Error", f"Error loading diff file:\n{e}") - - def _clean_diff_content(self, diff_content: str) -> str: - """Clean and fix common issues with AI-generated diff content""" - try: - lines = diff_content.split('\n') - cleaned_lines = [] - - for i, line in enumerate(lines): - # Remove duplicate +++ lines that sometimes appear - if line.startswith('+++') and i > 0: - # Check if previous line was also +++ - prev_line = lines[i-1] if i > 0 else "" - if prev_line.startswith('+++'): - continue # Skip duplicate - - # Fix malformed file headers - if line.startswith('title:') and not line.startswith('---'): - # This looks like metadata that shouldn't be removed - continue - - cleaned_lines.append(line) - - cleaned_diff = '\n'.join(cleaned_lines) - - # If the diff looks seriously malformed, add a warning - if '+++' in cleaned_diff and cleaned_diff.count('+++') > 2: - warning = "⚠️ WARNING: This diff may have formatting issues. Please review carefully.\n\n" - return warning + cleaned_diff - - return cleaned_diff - - except Exception as e: - self.logger.log(f"⚠️ Error cleaning diff content: {e}") - return diff_content # Return original if cleaning fails \ No newline at end of file + 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() + + +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/application/app_components/settings_dialog.py b/application/app_components/settings_dialog.py index 43c8e63..7923dc5 100644 --- a/application/app_components/settings_dialog.py +++ b/application/app_components/settings_dialog.py @@ -1,590 +1,523 @@ """ Settings Dialog -GUI for configuring application settings +GUI for configuring application settings (Flet version) """ -import tkinter as tk +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 threading import subprocess -from tkinter import ttk, messagebox, scrolledtext from typing import Dict, Any, Optional import sys import os +import asyncio class SettingsDialog: """Settings configuration dialog""" - - def __init__(self, parent, config: Dict[str, Any], config_manager=None, cache_manager=None): - self.parent = parent + + 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 = {} - - # Create dialog - self.dialog = tk.Toplevel(parent) - self.dialog.title("⚙️ Settings") - self.dialog.geometry("900x1000") - self.dialog.resizable(True, True) - self.dialog.transient(parent) - self.dialog.grab_set() - - self._create_widgets() - self._bind_events() - - def _create_widgets(self): - """Create dialog widgets""" - # Main frame with scrollbar - main_frame = ttk.Frame(self.dialog, padding="20") - main_frame.pack(fill=tk.BOTH, expand=True) - - # Create notebook for tabbed settings - notebook = ttk.Notebook(main_frame) - notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 20)) - + self.dialog_ref = ft.Ref[ft.AlertDialog]() + + # Repository data + self.target_repos = [] + self.forked_repos = [] + + # Dropdown refs + self.target_repo_dropdown_ref = ft.Ref[ft.Dropdown]() + self.forked_repo_dropdown_ref = ft.Ref[ft.Dropdown]() + self.detected_repos_dropdown_ref = ft.Ref[ft.Dropdown]() + self.ollama_model_dropdown_ref = ft.Ref[ft.Dropdown]() + + 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}") + + # IMPORTANT: Set the reference before opening + if self.dialog_ref.current is None: + print("dialog_ref.current is None, setting it now") + 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) + 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() + await self._load_target_repos_async() + await self._load_user_forks_async() + + def _create_dialog(self) -> ft.AlertDialog: + """Create the settings dialog""" # Create tabs - self._create_general_tab(notebook) - self._create_ai_tab(notebook) - # Removed: self._create_dataverse_tab(notebook) - Azure DevOps/Dataverse specific - - # Buttons frame - buttons_frame = ttk.Frame(main_frame) - buttons_frame.pack(fill=tk.X, pady=(10, 0)) - - # Buttons - ttk.Button(buttons_frame, text="💾 Save Settings", command=self._save_clicked).pack(side=tk.RIGHT, padx=(5, 0)) - ttk.Button(buttons_frame, text="❌ Cancel", command=self._cancel_clicked).pack(side=tk.RIGHT) - ttk.Button(buttons_frame, text="🗑️ Clear Cache", command=self._clear_cache).pack(side=tk.LEFT, padx=(5, 0)) - ttk.Button(buttons_frame, text="Test Connection", command=self._test_connection).pack(side=tk.LEFT) - - # Center dialog after everything is created - self._center_dialog() - - def _create_general_tab(self, notebook): - """Create general settings tab""" - general_frame = ttk.Frame(notebook) - notebook.add(general_frame, text="General") - - # Scrollable frame - canvas = tk.Canvas(general_frame) - scrollbar = ttk.Scrollbar(general_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - - scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + 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, ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - # Configure column weights for proper expansion - scrollable_frame.columnconfigure(1, weight=1) - - current_row = 0 - # REMOVED: Azure DevOps Configuration section - # This was specific to Azure DevOps integration + # 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, + ) - # GitHub section - self._create_section_header(scrollable_frame, current_row, "🐙 GitHub Configuration") - current_row += 1 - - self._create_label_entry(scrollable_frame, current_row, "Personal Access Token:", 'GITHUB_PAT', password=True, width=60) - current_row += 1 - - self._create_target_repo_dropdown(scrollable_frame, current_row) - current_row += 1 + 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), + ) - self._create_forked_repo_dropdown(scrollable_frame, current_row) - current_row += 1 - - # General options section - self._create_section_header(scrollable_frame, current_row, "⚙️ General Options") - current_row += 1 - - self._create_dry_run_checkbox(scrollable_frame, current_row) - current_row += 1 - - self._create_label_entry(scrollable_frame, current_row, "Local Repo Path:", 'LOCAL_REPO_PATH', width=60) - current_row += 1 + return dialog - # Detected repos dropdown - ttk.Label(scrollable_frame, text="Detected Repos:", font=('Arial', 10, 'bold')).grid( - row=current_row, column=0, sticky=tk.W, pady=5, padx=10) + def _create_general_tab(self) -> ft.Container: + """Create general settings tab""" + controls = [] - # Frame for dropdown and refresh button - detected_frame = ttk.Frame(scrollable_frame) - detected_frame.grid(row=current_row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) + # GitHub Configuration Section + controls.append(self._create_section_header("🐙 GitHub Configuration")) - self.detected_repos_var = tk.StringVar(value='Scanning...') - self.detected_repos_dropdown = ttk.Combobox(detected_frame, textvariable=self.detected_repos_var, - state='readonly', width=45) - self.detected_repos_dropdown.pack(side=tk.LEFT, fill=tk.X, expand=True) - self.detected_repos_dropdown.bind('<>', self._on_repo_selected) + # 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) - refresh_button = ttk.Button(detected_frame, text="🔄 Scan", command=self._scan_repos, width=8) - refresh_button.pack(side=tk.LEFT, padx=(5, 0)) - current_row += 1 + # Target Repository + controls.append(ft.Text("Target Repository", weight=ft.FontWeight.BOLD, size=14)) + target_repo_row = ft.Row( + [ + ft.Dropdown( + ref=self.target_repo_dropdown_ref, + label="Target Repository", + value=self.config.get('GITHUB_REPO', ''), + options=[], + hint_text="Select or type repository", + expand=True, + on_change=lambda e: self._on_target_repo_search(e), + ), + 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, + ) + controls.append(target_repo_row) + controls.append(ft.Text( + "ℹ️ Upstream repo where PRs will be created. Type to search all GitHub repos.", + size=12, + color="grey400", + )) - # Help text for local repo path - repo_help = ttk.Label(scrollable_frame, - 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" - " • Target Repository: Upstream repo for PRs (e.g., microsoft/repo)\n" - " • Fork Workflow: Work on your fork locally, create PRs to upstream", - font=('Arial', 9), foreground='gray', justify=tk.LEFT, wraplength=850) - repo_help.grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 20), padx=10) - current_row += 1 + # Forked Repository + controls.append(ft.Text("Forked Repository", weight=ft.FontWeight.BOLD, size=14)) + forked_repo_row = ft.Row( + [ + ft.Dropdown( + ref=self.forked_repo_dropdown_ref, + label="Forked Repository", + value=self.config.get('FORKED_REPO', ''), + options=[], + hint_text="Select your fork", + expand=True, + ), + 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, + ) + controls.append(forked_repo_row) + controls.append(ft.Text( + "ℹ️ Your fork where changes will be made. Leave empty to auto-detect from document URL.", + size=12, + color="grey400", + )) + + # 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 - help_text = ttk.Label(scrollable_frame, text="💡 Getting Started:\n" - "1. Create a GitHub Personal Access Token\n" - "2. Configure GitHub repositories:\n" - " • Target Repository: Where PRs will be created\n" - " • Forked Repository: Your fork where changes are made\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", - font=('Arial', 9), foreground='blue', justify=tk.LEFT, wraplength=850) - help_text.grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(20, 30), padx=10) + 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" + " • Target Repository: Upstream repo for PRs (e.g., microsoft/repo)\n" + " • Fork Workflow: Work on your fork locally, create PRs to upstream", + size=12, + color="grey400", + ), + padding=ft.padding.all(10), + bgcolor="surfacevariant", + border_radius=5, + margin=ft.margin.only(top=10), + )) - # Scan for repos after creating the UI - self.dialog.after(100, self._scan_repos) - - # Pack canvas and scrollbar - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - def _create_ai_tab(self, notebook): - """Create AI settings tab""" - ai_frame = ttk.Frame(notebook) - notebook.add(ai_frame, text="AI Providers") - - # Scrollable frame - canvas = tk.Canvas(ai_frame) - scrollbar = ttk.Scrollbar(ai_frame, orient="vertical", command=canvas.yview) - scrollable_frame = ttk.Frame(canvas) - - scrollable_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + controls.append(ft.Container( + content=ft.Text( + "💡 Getting Started:\n" + "1. Create a GitHub Personal Access Token\n" + "2. Configure GitHub repositories:\n" + " • Target Repository: Where PRs will be created\n" + " • Forked Repository: Your fork where changes are made\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, ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - # AI Provider section - self._create_section_header(scrollable_frame, 0, "🤖 AI Provider Configuration") - + + def _create_ai_tab(self) -> ft.Container: + """Create AI settings tab""" + controls = [] + + # AI Provider Section + controls.append(self._create_section_header("🤖 AI Provider Configuration")) + # Provider dropdown - ttk.Label(scrollable_frame, text="AI Provider:", font=('Arial', 10, 'bold')).grid( - row=1, column=0, sticky=tk.W, pady=5, padx=10) - - self.ai_provider_var = tk.StringVar(value=self.config.get('AI_PROVIDER', 'none')) - provider_dropdown = ttk.Combobox(scrollable_frame, textvariable=self.ai_provider_var, - values=['none', 'claude', 'chatgpt', 'github-copilot', 'ollama'], state='readonly', width=47) - provider_dropdown.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) - self.entries['AI_PROVIDER'] = self.ai_provider_var - + 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, + ) + self.entries['AI_PROVIDER'] = ai_provider + controls.append(ai_provider) + # API Keys - self._create_label_entry(scrollable_frame, 2, "Claude API Key:", 'CLAUDE_API_KEY', password=True) - self._create_label_entry(scrollable_frame, 3, "ChatGPT API Key:", 'OPENAI_API_KEY', password=True) - self._create_label_entry(scrollable_frame, 4, "GitHub Token (for Copilot) [defaults to GitHub PAT]:", 'GITHUB_TOKEN', password=True) + 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 - self._create_label_entry(scrollable_frame, 5, "Ollama Server URL:", 'OLLAMA_URL') - self._create_label_entry(scrollable_frame, 6, "Ollama API Key (optional):", 'OLLAMA_API_KEY', password=True) + controls.append(self._create_section_header("🦙 Ollama Configuration")) - # Ollama Model Dropdown - self._create_ollama_model_dropdown(scrollable_frame, 7) + 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_row = ft.Row( + [ + 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, + ), + 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 - help_text = ttk.Label(scrollable_frame, text="\n💡 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", - font=('Arial', 9), foreground='blue', justify=tk.LEFT, wraplength=800) - help_text.grid(row=8, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=20, padx=10) - - # Pack canvas and scrollbar - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # REMOVED: _create_dataverse_tab method - # This was specific to Azure DevOps/Dataverse integration - # def _create_dataverse_tab(self, notebook): - # """Create Dataverse/PowerApp settings tab""" - # ... - - def _create_section_header(self, parent, row: int, text: str): + 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""" - header_frame = ttk.Frame(parent) - header_frame.grid(row=row, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(20, 10), padx=10) - header_frame.columnconfigure(1, weight=1) - - ttk.Label(header_frame, text=text, font=('Arial', 12, 'bold')).grid(row=0, column=0, sticky=tk.W) - ttk.Separator(header_frame, orient='horizontal').grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0)) - - def _create_label_entry(self, parent, row: int, label_text: str, config_key: str, - password: bool = False, width: int = 50, multiline: bool = False): - """Create a label and entry pair""" - ttk.Label(parent, text=label_text, font=('Arial', 10, 'bold')).grid( - row=row, column=0, sticky=tk.W, pady=5, padx=10) - - if multiline: - entry = scrolledtext.ScrolledText(parent, height=3, width=width) - entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) - entry.insert('1.0', self.config.get(config_key, '') or '') - elif password: - entry = ttk.Entry(parent, show="*", width=width) - entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) - - # Special handling for GITHUB_TOKEN - show placeholder if using default - if config_key == 'GITHUB_TOKEN': - github_token = self.config.get('GITHUB_TOKEN', '').strip() - github_pat = self.config.get('GITHUB_PAT', '').strip() - if not github_token and github_pat: - # Show placeholder for defaulted value, but don't actually set it - entry.config(foreground='gray') - entry.insert(0, '(using GitHub PAT)') - - # Add event handlers to clear placeholder on focus - def on_focus_in(event): - if entry.get() == '(using GitHub PAT)': - entry.delete(0, tk.END) - entry.config(foreground='black') - - def on_focus_out(event): - if not entry.get(): - entry.config(foreground='gray') - entry.insert(0, '(using GitHub PAT)') - - entry.bind('', on_focus_in) - entry.bind('', on_focus_out) + 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), + ) + + 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") + + 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: + for owner_dir in base_path.iterdir(): + if not owner_dir.is_dir(): + continue + + for repo_dir in owner_dir.iterdir(): + if not repo_dir.is_dir(): + continue + + git_dir = repo_dir / ".git" + if git_dir.exists(): + repo_name = f"{owner_dir.name}/{repo_dir.name}" + repos.append(repo_name) + + except Exception as e: + print(f"Error scanning repos: {e}") + + # Update dropdown + if self.detected_repos_dropdown_ref.current: + if repos: + repos.sort() + 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: - entry.insert(0, github_token) - else: - entry.insert(0, self.config.get(config_key, '') or '') - else: - entry = ttk.Entry(parent, width=width) - entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) - entry.insert(0, self.config.get(config_key, '') or '') - - self.entries[config_key] = entry - parent.columnconfigure(1, weight=1) + self.detected_repos_dropdown_ref.current.value = 'No git repositories found' + self.detected_repos_dropdown_ref.current.options = [] - def _create_target_repo_dropdown(self, parent, row: int): - """Create target repository dropdown with search functionality""" - ttk.Label(parent, text="Target Repository:", font=('Arial', 10, 'bold')).grid( - row=row, column=0, sticky=tk.W, pady=5, padx=10) + self.page.update() - # Frame for dropdown, search entry, and buttons - dropdown_frame = ttk.Frame(parent) - dropdown_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) - dropdown_frame.columnconfigure(0, weight=1) - - # Placeholder for target repos - self.target_repos = [] - - # Combobox for target repo (searchable) - self.target_repo_var = tk.StringVar(value=self.config.get('GITHUB_REPO', '')) - self.target_repo_dropdown = ttk.Combobox(dropdown_frame, textvariable=self.target_repo_var, - values=[''], width=50) - self.target_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) - self.entries['GITHUB_REPO'] = self.target_repo_var - - # Bind typing event for search - self.target_repo_dropdown.bind('', self._on_target_repo_search) - - # Refresh button - refresh_btn = ttk.Button(dropdown_frame, text="🔄", width=3, - command=self._refresh_target_repos) - refresh_btn.grid(row=0, column=1, padx=(0, 2)) - - # Search button - search_btn = ttk.Button(dropdown_frame, text="🔍", width=3, - command=self._search_target_repos) - search_btn.grid(row=0, column=2) - - # Help text for target repo - help_label = ttk.Label(parent, - text=" ℹ️ Upstream repo where PRs will be created. Type to search all GitHub repos.", - font=('Arial', 9), foreground='gray') - help_label.grid(row=row+1, column=0, columnspan=3, sticky=tk.W, padx=10) - - # Start async loading of user's repos with edit access - self.dialog.after(100, self._load_target_repos_async) - - def _create_forked_repo_dropdown(self, parent, row: int): - """Create forked repository dropdown with local repo detection""" - ttk.Label(parent, text="Forked Repository:", font=('Arial', 10, 'bold')).grid( - row=row, column=0, sticky=tk.W, pady=5, padx=10) - - # Frame for dropdown and refresh button - dropdown_frame = ttk.Frame(parent) - dropdown_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) - dropdown_frame.columnconfigure(0, weight=1) - - # Initial options - repo_options = [''] # Empty option - - # Add local repositories - local_repo_path = self.config.get('LOCAL_REPO_PATH', '') - if local_repo_path: - try: - from .utils import LocalRepositoryScanner - local_repos = LocalRepositoryScanner.scan_local_repos(local_repo_path) - if local_repos: - repo_options.append('--- Local Repositories ---') - repo_options.extend(local_repos) - except Exception as e: - print(f"Error scanning local repos: {e}") - - # Placeholder for user's forks (will be populated asynchronously) - self.forked_repos = [] - - self.forked_repo_var = tk.StringVar(value=self.config.get('FORKED_REPO', '')) - self.forked_repo_dropdown = ttk.Combobox(dropdown_frame, textvariable=self.forked_repo_var, - values=repo_options, width=50) - self.forked_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) - self.entries['FORKED_REPO'] = self.forked_repo_var - - # Refresh button - refresh_btn = ttk.Button(dropdown_frame, text="🔄", width=3, - command=self._refresh_forked_repos) - refresh_btn.grid(row=0, column=1, padx=(0, 2)) - - # Clone button - clone_btn = ttk.Button(dropdown_frame, text="📥", width=3, - command=self._clone_forked_repo) - clone_btn.grid(row=0, column=2) - - # Help text for forked repo - help_label = ttk.Label(parent, - text=" ℹ️ Your fork where changes will be made. Leave empty to auto-detect from document URL.", - font=('Arial', 9), foreground='gray') - help_label.grid(row=row+1, column=0, columnspan=3, sticky=tk.W, padx=10) - - # Start async loading of user's forks - self.dialog.after(100, self._load_user_forks_async) - - def _refresh_forked_repos(self): - """Refresh the forked repositories dropdown""" - self._load_user_forks_async() - - # Also refresh local repos - local_repo_path = self.config.get('LOCAL_REPO_PATH', '') - if local_repo_path: - try: - from .utils import LocalRepositoryScanner - local_repos = LocalRepositoryScanner.scan_local_repos(local_repo_path) - - # Update dropdown with current values plus refreshed local repos - current_values = list(self.forked_repo_dropdown['values']) - - # Remove old local repos section - if '--- Local Repositories ---' in current_values: - start_idx = current_values.index('--- Local Repositories ---') - # Find where GitHub repos start or end of list - end_idx = len(current_values) - for i in range(start_idx + 1, len(current_values)): - if current_values[i].startswith('--- ') and 'GitHub' in current_values[i]: - end_idx = i - break - - # Remove local repos section - current_values = current_values[:start_idx] + current_values[end_idx:] - - # Add refreshed local repos - if local_repos: - current_values.insert(1, '--- Local Repositories ---') - for i, repo in enumerate(local_repos): - current_values.insert(2 + i, repo) - - self.forked_repo_dropdown['values'] = current_values - - except Exception as e: - print(f"Error refreshing local repos: {e}") - - def _clone_forked_repo(self): - """Clone the selected forked repository to the local repo path""" - # Get selected repository - selected_repo = self.forked_repo_var.get().strip() - - # Validate selection - if not selected_repo: - messagebox.showwarning("No Repository Selected", - "Please select a repository to clone.") - return - - # Check if it's a section header - if selected_repo.startswith('---'): - messagebox.showwarning("Invalid Selection", - "Please select a repository, not a section header.") - return - - # Get local repo path - local_repo_path = self.config.get('LOCAL_REPO_PATH', '').strip() - if not local_repo_path: - messagebox.showwarning("Local Path Not Configured", - "Please configure the Local Repository Path in settings first.") - return - - # Create directory if it doesn't exist - try: - os.makedirs(local_repo_path, exist_ok=True) except Exception as e: - messagebox.showerror("Directory Error", - f"Could not create local repository directory:\n{str(e)}") - return + print(f"Error in _scan_repos_async: {e}") - # Extract repo name (handle both "owner/repo" and URLs) - repo_name = selected_repo - if repo_name.startswith('http'): - # Extract from URL - parts = repo_name.rstrip('/').split('/') - if len(parts) >= 2: - repo_name = f"{parts[-2]}/{parts[-1]}" - else: - messagebox.showerror("Invalid Repository", - "Could not parse repository name from URL.") - return - - # Validate format "owner/repo" - if '/' not in repo_name: - messagebox.showerror("Invalid Repository", - "Repository must be in 'owner/repo' format.") - return - - # Extract just the repo name for the folder - folder_name = repo_name.split('/')[-1] - target_path = os.path.join(local_repo_path, folder_name) - - # Check if directory already exists - if os.path.exists(target_path): - response = messagebox.askyesno("Directory Exists", - f"The directory '{folder_name}' already exists.\n\n" - f"Do you want to continue anyway?\n" - f"(This may fail if it's already a git repository)") - if not response: - return - - # Construct clone URL - clone_url = f"https://github.com/{repo_name}.git" - - # Clone in background thread - def clone_repo(): - try: - # Run git clone - result = subprocess.run( - ['git', 'clone', clone_url, target_path], - capture_output=True, - text=True, - timeout=300 # 5 minute timeout - ) - - # Update UI on main thread - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: self._handle_clone_result(result, repo_name, folder_name)) - - except subprocess.TimeoutExpired: - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: messagebox.showerror( - "Clone Timeout", - f"Cloning {repo_name} timed out after 5 minutes.")) - except FileNotFoundError: - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: messagebox.showerror( - "Git Not Found", - "Git is not installed or not found in PATH.\n\n" - "Please install Git from: https://git-scm.com/downloads")) - except Exception as e: - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: messagebox.showerror( - "Clone Error", - f"An error occurred while cloning:\n{str(e)}")) - - # Show progress message - messagebox.showinfo("Cloning Repository", - f"Cloning {repo_name} to:\n{target_path}\n\n" - f"This may take a few moments...") - - # Start clone in background - thread = threading.Thread(target=clone_repo, daemon=True) - thread.start() - - def _handle_clone_result(self, result, repo_name: str, folder_name: str): - """Handle the result of a git clone operation""" - if result.returncode == 0: - messagebox.showinfo("Clone Successful", - f"Successfully cloned {repo_name}!\n\n" - f"Location: {folder_name}/") - # Refresh the dropdown to show the newly cloned repo - self._refresh_forked_repos() - else: - error_msg = result.stderr if result.stderr else result.stdout - messagebox.showerror("Clone Failed", - f"Failed to clone {repo_name}.\n\n" - f"Error:\n{error_msg}") - - def _load_user_forks_async(self): - """Load user's GitHub forks asynchronously""" - def load_forks(): - try: - github_token = self.config.get('GITHUB_PAT', '') - if not github_token: - return - - from .workflow import GitHubRepoFetcher - repo_fetcher = GitHubRepoFetcher(github_token) - repos = repo_fetcher.fetch_user_repos(repo_type='owner') - self.forked_repos = repo_fetcher.get_repo_names(repos) - - # Update dropdown on main thread - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, self._update_forked_dropdown) - - except Exception as e: - print(f"Error loading user forks: {e}") - - threading.Thread(target=load_forks, daemon=True).start() - - def _update_forked_dropdown(self): - """Update the forked repository dropdown with GitHub forks""" - try: - # Check if dialog and dropdown still exist - if not hasattr(self, 'dialog') or not self.dialog.winfo_exists(): - return - if not hasattr(self, 'forked_repo_dropdown') or not self.forked_repo_dropdown.winfo_exists(): - return - - current_values = list(self.forked_repo_dropdown['values']) - - # Remove old GitHub repos section if exists - if '--- Your GitHub Repos ---' in current_values: - start_idx = current_values.index('--- Your GitHub Repos ---') - current_values = current_values[:start_idx] - - # Add GitHub repos section - if self.forked_repos: - current_values.append('--- Your GitHub Repos ---') - current_values.extend(self.forked_repos) - - self.forked_repo_dropdown['values'] = current_values - - except Exception as e: - print(f"Error updating forked dropdown: {e}") - - def _load_target_repos_async(self): + async def _load_target_repos_async(self): """Load target repos (with push/admin access) asynchronously""" def load_repos(): try: @@ -597,42 +530,42 @@ class SettingsDialog: repos = repo_fetcher.fetch_repos_with_permissions(min_permission='push') self.target_repos = repo_fetcher.get_repo_names(repos) - # Update dropdown on main thread - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, self._update_target_dropdown) + # Update UI on main thread + if self.target_repo_dropdown_ref.current: + self.page.run_task(self._update_target_dropdown_async) except Exception as e: print(f"Error loading target repos: {e}") - threading.Thread(target=load_repos, daemon=True).start() + await asyncio.to_thread(load_repos) - def _update_target_dropdown(self): + async def _update_target_dropdown_async(self): """Update the target repository dropdown""" try: - if not hasattr(self, 'dialog') or not self.dialog.winfo_exists(): - return - if not hasattr(self, 'target_repo_dropdown') or not self.target_repo_dropdown.winfo_exists(): + if not self.target_repo_dropdown_ref.current: return - current_values = [''] # Start with empty option - - # Add user's repos with edit access + options = [] if self.target_repos: - current_values.append('--- Your Repos (with edit access) ---') - current_values.extend(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['values'] = current_values + self.target_repo_dropdown_ref.current.options = options + self.page.update() except Exception as e: print(f"Error updating target dropdown: {e}") - def _refresh_target_repos(self): + async def _refresh_target_repos_async(self): """Refresh target repositories""" - self._load_target_repos_async() + await self._load_target_repos_async() - def _search_target_repos(self): + async def _search_target_repos_async(self): """Search for repositories on GitHub""" - query = self.target_repo_var.get().strip() + if not self.target_repo_dropdown_ref.current: + return + + query = self.target_repo_dropdown_ref.current.value.strip() if not query: return @@ -647,102 +580,172 @@ class SettingsDialog: repos = repo_fetcher.search_repositories(query, per_page=50) search_results = repo_fetcher.get_repo_names(repos) - # Update dropdown on main thread - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: self._update_target_dropdown_with_search(search_results, query)) + # Update UI + if self.target_repo_dropdown_ref.current: + 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]) + + if search_results: + options.append(ft.dropdown.Option(f"--- Search Results for \"{query}\" ---", disabled=True)) + options.extend([ft.dropdown.Option(repo) for repo in search_results]) + + self.target_repo_dropdown_ref.current.options = options + self.page.update() except Exception as e: print(f"Error searching repos: {e}") - threading.Thread(target=search_repos, daemon=True).start() + await asyncio.to_thread(search_repos) - def _update_target_dropdown_with_search(self, search_results, query): - """Update target dropdown with search results""" + def _on_target_repo_search(self, e): + """Handle typing in target repo field for auto-search""" + # Debounce search - could be implemented with a timer + pass + + async def _load_user_forks_async(self): + """Load user's GitHub forks asynchronously""" + def load_forks(): + try: + github_token = self.config.get('GITHUB_PAT', '') + if not github_token: + return + + from .workflow import GitHubRepoFetcher + repo_fetcher = GitHubRepoFetcher(github_token) + repos = repo_fetcher.fetch_user_repos(repo_type='owner') + self.forked_repos = 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: + print(f"Error loading user forks: {e}") + + await asyncio.to_thread(load_forks) + + async def _update_forked_dropdown_async(self): + """Update the forked repository dropdown with GitHub forks""" try: - if not hasattr(self, 'target_repo_dropdown') or not self.target_repo_dropdown.winfo_exists(): + if not self.forked_repo_dropdown_ref.current: return - current_values = [''] + options = [] - # Add user's repos - if self.target_repos: - current_values.append('--- Your Repos (with edit access) ---') - current_values.extend(self.target_repos) + # Add local repos + local_repo_path = self.config.get('LOCAL_REPO_PATH', '') + if local_repo_path: + try: + from .utils import LocalRepositoryScanner + local_repos = LocalRepositoryScanner.scan_local_repos(local_repo_path) + if local_repos: + options.append(ft.dropdown.Option("--- Local Repositories ---", disabled=True)) + options.extend([ft.dropdown.Option(repo) for repo in local_repos]) + except Exception as e: + print(f"Error scanning local repos: {e}") - # Add search results - if search_results: - current_values.append(f'--- Search Results for "{query}" ---') - current_values.extend(search_results) + # Add GitHub repos + if self.forked_repos: + options.append(ft.dropdown.Option("--- Your GitHub Repos ---", disabled=True)) + options.extend([ft.dropdown.Option(repo) for repo in self.forked_repos]) - self.target_repo_dropdown['values'] = current_values + self.forked_repo_dropdown_ref.current.options = options + self.page.update() except Exception as e: - print(f"Error updating target dropdown with search: {e}") + print(f"Error updating forked dropdown: {e}") - def _on_target_repo_search(self, _event): - """Handle typing in target repo field for auto-search""" - # Debounce: only search after user stops typing for 500ms - if hasattr(self, '_search_timer'): - self.dialog.after_cancel(self._search_timer) + async def _refresh_forked_repos_async(self): + """Refresh the forked repositories dropdown""" + await self._load_user_forks_async() + await self._update_forked_dropdown_async() - query = self.target_repo_var.get().strip() - if len(query) >= 3: # Only search if at least 3 characters - self._search_timer = self.dialog.after(500, self._search_target_repos) - - def _create_ollama_model_dropdown(self, parent, row: int): - """Create Ollama model dropdown with scan button""" - ttk.Label(parent, text="Ollama Model:", font=('Arial', 10, 'bold')).grid( - row=row, column=0, sticky=tk.W, pady=5, padx=10) - - # Frame for dropdown and scan button - dropdown_frame = ttk.Frame(parent) - dropdown_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10) - dropdown_frame.columnconfigure(0, weight=1) - - # Model dropdown - self.ollama_model_var = tk.StringVar(value=self.config.get('OLLAMA_MODEL', '')) - self.ollama_model_dropdown = ttk.Combobox(dropdown_frame, textvariable=self.ollama_model_var, - values=[''], width=47) - self.ollama_model_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) - self.entries['OLLAMA_MODEL'] = self.ollama_model_var - - # Scan button - scan_btn = ttk.Button(dropdown_frame, text="🔍", width=3, - command=self._scan_ollama_models) - scan_btn.grid(row=0, column=1) - - # Help text for Ollama model - help_label = ttk.Label(parent, - text=" ℹ️ Click 🔍 to scan available models from your Ollama server.", - font=('Arial', 9), foreground='gray') - help_label.grid(row=row+1, column=0, columnspan=3, sticky=tk.W, padx=10) - - def _scan_ollama_models(self): - """Scan Ollama server for available models""" - ollama_url = self.entries.get('OLLAMA_URL').get().strip() if 'OLLAMA_URL' in self.entries else '' - - if not ollama_url: - messagebox.showwarning("Ollama URL Required", - "Please enter the Ollama Server URL first.") + def _clone_forked_repo(self, e): + """Clone the selected forked repository to the local repo path""" + if not self.forked_repo_dropdown_ref.current: + return + + selected_repo = self.forked_repo_dropdown_ref.current.value.strip() + + if not selected_repo or selected_repo.startswith('---'): + self._show_alert("Invalid Selection", "Please select a repository, not a section header.") + return + + local_repo_path = self.config.get('LOCAL_REPO_PATH', '').strip() + if not local_repo_path: + self._show_alert("Local Path Not Configured", "Please configure the Local Repository Path in settings first.") + return + + # Start clone in background + self.page.run_task(lambda: self._clone_repo_async(selected_repo, local_repo_path)) + + async def _clone_repo_async(self, repo_name: str, local_repo_path: str): + """Clone repository asynchronously""" + try: + os.makedirs(local_repo_path, exist_ok=True) + + if '/' not in repo_name: + self._show_alert("Invalid Repository", "Repository must be in 'owner/repo' format.") + return + + folder_name = repo_name.split('/')[-1] + target_path = os.path.join(local_repo_path, folder_name) + + if os.path.exists(target_path): + # Show confirmation dialog + self._show_alert( + "Directory Exists", + f"The directory '{folder_name}' already exists. Clone may fail if it's already a git repository." + ) + return + + clone_url = f"https://github.com/{repo_name}.git" + + # Show progress + self._show_alert("Cloning Repository", f"Cloning {repo_name}...\nThis may take a few moments.") + + # Run git clone + result = await asyncio.to_thread( + subprocess.run, + ['git', 'clone', clone_url, target_path], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + self._show_alert("Clone Successful", f"Successfully cloned {repo_name}!\n\nLocation: {folder_name}/") + await self._refresh_forked_repos_async() + else: + error_msg = result.stderr if result.stderr else result.stdout + self._show_alert("Clone Failed", f"Failed to clone {repo_name}.\n\nError:\n{error_msg}") + + except Exception as e: + self._show_alert("Clone Error", f"An error occurred while cloning:\n{str(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 - # Normalize URL if not ollama_url.startswith('http'): ollama_url = f"http://{ollama_url}" - # Scan in background thread def scan_models(): try: import requests - # Get API key if provided - ollama_api_key = self.entries.get('OLLAMA_API_KEY').get().strip() if 'OLLAMA_API_KEY' in self.entries else '' + 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}' - # Query Ollama API for models response = requests.get(f"{ollama_url}/api/tags", headers=headers, timeout=10) response.raise_for_status() @@ -750,140 +753,53 @@ class SettingsDialog: models = data.get('models', []) model_names = [model.get('name', '') for model in models if model.get('name')] - # Update UI on main thread - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: self._update_ollama_models(model_names)) + # 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 + ] + if model_names: + 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" + + self._show_alert("Models Found", f"Found {len(model_names)} model(s):\n\n{models_text}") + 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: - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: messagebox.showerror( - "Connection Error", - f"Could not connect to Ollama server at:\n{ollama_url}\n\n" - f"Make sure Ollama is running and the URL is correct.")) - except requests.exceptions.Timeout: - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: messagebox.showerror( - "Timeout", - f"Connection to Ollama server timed out.")) - except requests.exceptions.HTTPError as e: - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - if e.response.status_code == 401: - self.dialog.after(0, lambda: messagebox.showerror( - "Authentication Error", - "Invalid API key. Please check your Ollama API Key.")) - else: - self.dialog.after(0, lambda: messagebox.showerror( - "HTTP Error", - f"Error from Ollama server:\n{e}")) + 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: - if hasattr(self, 'dialog') and self.dialog.winfo_exists(): - self.dialog.after(0, lambda: messagebox.showerror( - "Scan Error", - f"An error occurred while scanning for models:\n{str(e)}")) + self._show_alert("Scan Error", f"An error occurred while scanning for models:\n{str(e)}") - # Start scan in background - threading.Thread(target=scan_models, daemon=True).start() + await asyncio.to_thread(scan_models) - def _update_ollama_models(self, model_names): - """Update the Ollama model dropdown with scanned models""" - if not model_names: - messagebox.showinfo("No Models Found", - "No models found on the Ollama server.\n\n" - "Use 'ollama pull ' to download models.") - return - - try: - if hasattr(self, 'ollama_model_dropdown') and self.ollama_model_dropdown.winfo_exists(): - current_value = self.ollama_model_var.get() - self.ollama_model_dropdown['values'] = model_names - - # Keep current selection if it's still in the list - if current_value not in model_names and model_names: - self.ollama_model_var.set(model_names[0]) - - messagebox.showinfo("Models Found", - f"Found {len(model_names)} model(s):\n\n" + - "\n".join(f"• {name}" for name in model_names[:10]) + - (f"\n\n...and {len(model_names) - 10} more" if len(model_names) > 10 else "")) - - except Exception as e: - print(f"Error updating Ollama models: {e}") - - def _create_dry_run_checkbox(self, parent, row: int): - """Create dry run checkbox""" - self.dry_run_var = tk.BooleanVar() - dry_run_value = self.config.get('DRY_RUN', 'false') - self.dry_run_var.set(str(dry_run_value).lower() in ('true', '1', 'yes', 'on')) - - dry_run_frame = ttk.Frame(parent) - dry_run_frame.grid(row=row, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10, padx=10) - - dry_run_checkbox = ttk.Checkbutton( - dry_run_frame, - text="🧪 Dry Run Mode (Test without making changes)", - variable=self.dry_run_var - ) - dry_run_checkbox.pack(side=tk.LEFT) - - help_label = ttk.Label(dry_run_frame, - text=" ℹ️ Simulates operations without creating actual GitHub issues/PRs", - font=('Arial', 9), foreground='gray') - help_label.pack(side=tk.LEFT) - - self.entries['DRY_RUN'] = self.dry_run_var - - def _scan_repos(self): - """Scan work items to detect commonly used repositories""" - try: - # This is a placeholder - could be enhanced to actually scan work items - # and suggest repositories based on document URLs found - pass - except Exception as e: - print(f"Could not scan repositories: {e}") - - def _bind_events(self): - """Bind keyboard events""" - self.dialog.bind('', lambda e: self._save_clicked()) - self.dialog.bind('', lambda e: self._cancel_clicked()) - - # Set focus to first entry if available - if self.entries: - first_entry = next(iter(self.entries.values())) - if hasattr(first_entry, 'focus_set'): - first_entry.focus_set() - - def _test_connection(self): + def _test_connection(self, e): """Test connection to configured services""" - # Get current values config_values = self._get_config_values() - + results = [] - - # REMOVED: Azure DevOps test connection - # This was specific to Azure DevOps integration # Test GitHub if config_values.get('GITHUB_PAT'): try: - # Try to import and test GitHub API from .github_api import GitHubAPI api = GitHubAPI(config_values.get('GITHUB_PAT')) - - # Basic connection test 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 ImportError: - results.append("GitHub: ⚠️ Token set (API module not available)") 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': @@ -891,304 +807,187 @@ class SettingsDialog: 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 ImportError: - results.append(f"AI Provider ({ai_provider}): ⚠️ Configuration set (AI manager not available)") + 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: - messagebox.showinfo("Connection Test Results", - "\n".join(results) + "\n\n💡 Full validation requires running the application.", - parent=self.dialog) - else: - messagebox.showwarning("Connection Test", "No configuration to test.", parent=self.dialog) - - def _center_dialog(self): - """Center the dialog over the parent window""" - self.dialog.update_idletasks() + self._show_alert( + "Connection Test Results", + "\n".join(results) + "\n\n💡 Full validation requires running the application." + ) - # Get parent window position and size - self.parent.update_idletasks() - x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (self.dialog.winfo_width() // 2) - y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (self.dialog.winfo_height() // 2) + 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") - self.dialog.geometry(f"+{x}+{y}") + # 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, tk.BooleanVar): - config_values[key] = 'true' if widget.get() else 'false' - elif isinstance(widget, tk.StringVar): - config_values[key] = widget.get().strip() - elif isinstance(widget, scrolledtext.ScrolledText): - config_values[key] = widget.get('1.0', tk.END).strip() - elif isinstance(widget, ttk.Combobox): - config_values[key] = widget.get().strip() - else: # Entry widget - value = widget.get().strip() - # Special handling for GITHUB_TOKEN placeholder - if key == 'GITHUB_TOKEN' and value == '(using GitHub PAT)': - value = '' # Save empty string when using placeholder + 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 - + + # Handle dropdown values specially + if self.target_repo_dropdown_ref.current: + config_values['GITHUB_REPO'] = self.target_repo_dropdown_ref.current.value or '' + if self.forked_repo_dropdown_ref.current: + config_values['FORKED_REPO'] = self.forked_repo_dropdown_ref.current.value or '' + if self.ollama_model_dropdown_ref.current: + config_values['OLLAMA_MODEL'] = self.ollama_model_dropdown_ref.current.value or '' + return config_values - - def _save_clicked(self): + + def _save_clicked(self, e): """Handle save button click""" try: - # Get configuration values config_values = self._get_config_values() - - # Validate required fields - required_for_basic = ['GITHUB_PAT'] - missing_basic = [field for field in required_for_basic if not config_values.get(field)] - if missing_basic: - messagebox.showwarning( + # Validate required fields + if not config_values.get('GITHUB_PAT'): + self._show_alert( "Missing Configuration", - f"The following required fields are missing:\n\n" - f"• {', '.join(missing_basic)}\n\n" - f"GitHub Personal Access Token is required for basic functionality." + "GitHub Personal Access Token is required for basic functionality." ) return - - # Check AI provider setup before saving + + # 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']: try: - # Import here to avoid circular imports from .ai_manager import AIManager ai_manager = AIManager() - available, missing = ai_manager.check_ai_module_availability(ai_provider) if not available: - # Offer to install missing packages - install_success = ai_manager.install_ai_packages(missing, self.dialog) - if not install_success: - # Installation failed or was cancelled, but still save settings - messagebox.showwarning("AI Modules Not Installed", - f"Settings saved, but AI provider '{ai_provider}' " - f"requires additional packages: {', '.join(missing)}\n\n" - f"You can install them later with:\n" - f"pip install {' '.join(missing)}", - parent=self.dialog) + # Show warning but continue + self._show_alert( + "AI Modules Not Installed", + f"Settings saved, but AI provider '{ai_provider}' requires additional packages: {', '.join(missing)}\n\n" + f"You can install them later with:\npip install {' '.join(missing)}" + ) except ImportError: - # AIManager not available, skip AI validation pass - - # Save configuration using the provided config manager + + # Save configuration if self.config_manager: success = self.config_manager.save_configuration(config_values) else: - # Fallback: create new config manager or save directly to file - try: - from .config_manager import ConfigManager - config_manager = ConfigManager() - success = config_manager.save_configuration(config_values) - except ImportError: - # Fallback to basic file saving if ConfigManager not available - success = self._save_to_env_file(config_values) - + success = self._save_to_env_file(config_values) + if success: self.result = config_values - - # Ask user if they want to restart the application - restart = messagebox.askyesno( + self._show_alert( "Settings Saved", - "Settings have been saved to .env file!\n\n" - "Would you like to restart the application now to apply changes?", - parent=self.dialog + "Settings saved successfully!\n\nChanges applied immediately - no restart needed! ✨" ) - - self.dialog.destroy() - - if restart: - self._restart_application() + self._close_dialog() else: - messagebox.showerror("Save Error", - "Failed to save settings to .env file.", - parent=self.dialog) - + self._show_alert("Save Error", "Failed to save settings to .env file.") + except Exception as e: - messagebox.showerror("Save Error", - f"Error saving settings:\n{str(e)}", - parent=self.dialog) - + 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: - import os - - # Create .env content env_content = "# GitHub Pulse Configuration\n" env_content += "# Generated by Settings Dialog\n\n" - - # Add all configuration values + for key, value in config_values.items(): - if value: # Only add non-empty values + if value: env_content += f"{key}={value}\n" else: env_content += f"{key}=\n" - - # Write to .env file + 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 _on_repo_selected(self, event=None): - """Handle repo selection from dropdown - informational only for fork workflow""" - # The detected repo dropdown shows which FORK the AI will work on locally - # The GITHUB_REPO field is the UPSTREAM repo where PRs are created - # This supports the fork workflow: work on fork, PR to upstream - pass - - def _scan_repos(self): - """Scan for git repositories in the local repo path""" - try: - from pathlib import Path - - # Get the local repo path from the entry field - local_path = self.entries.get('LOCAL_REPO_PATH') - if local_path and hasattr(local_path, 'get'): - path_str = local_path.get().strip() - else: - path_str = self.config.get('LOCAL_REPO_PATH', '').strip() - - # If no path configured, use default - if not path_str: - path_str = str(Path.home() / "Downloads" / "github_repos") - - base_path = Path(path_str) - - # Check if path exists - if not base_path.exists(): - self.detected_repos_var.set('No repos found (directory does not exist)') - self.detected_repos_dropdown['values'] = [] - return - - # Scan for git repositories - repos = [] - try: - # Look for owner/repo structure: base_path/owner/repo/.git - for owner_dir in base_path.iterdir(): - if not owner_dir.is_dir(): - continue - - for repo_dir in owner_dir.iterdir(): - if not repo_dir.is_dir(): - continue - - # Check if it's a git repo - git_dir = repo_dir / ".git" - if git_dir.exists(): - repo_name = f"{owner_dir.name}/{repo_dir.name}" - repos.append(repo_name) - - except PermissionError: - self.detected_repos_var.set('Permission denied accessing directory') - self.detected_repos_dropdown['values'] = [] - return - except Exception as e: - self.detected_repos_var.set(f'Error scanning: {str(e)[:50]}') - self.detected_repos_dropdown['values'] = [] - return - - # Update dropdown - if repos: - repos.sort() - self.detected_repos_dropdown['values'] = repos - - # Auto-select if only one repo found - if len(repos) == 1: - self.detected_repos_var.set(repos[0]) - # Trigger the selection handler to offer auto-populating GITHUB_REPO - self.dialog.after(200, self._on_repo_selected) - else: - self.detected_repos_var.set(f'{len(repos)} repo(s) found - select one') - else: - self.detected_repos_var.set('No git repositories found') - self.detected_repos_dropdown['values'] = [] - - except Exception as e: - self.detected_repos_var.set(f'Error: {str(e)[:50]}') - self.detected_repos_dropdown['values'] = [] - - def _restart_application(self): - """Restart the application""" - try: - # Get the parent root window (main application) - root = self.parent - while root.master: - root = root.master - - # Close the main window - root.quit() - - # Restart the application using the same Python executable and script - python = sys.executable - script = sys.argv[0] - - # If running as a module (python -m), preserve that - if script.endswith('__main__.py'): - # Running as module, restart with module syntax - os.execl(python, python, '-m', 'app') - else: - # Running as script, restart directly - os.execl(python, python, script, *sys.argv[1:]) - - except Exception as e: - messagebox.showerror( - "Restart Failed", - f"Could not restart application automatically:\n{str(e)}\n\n" - "Please restart the application manually.", - parent=self.parent - ) - - def _cancel_clicked(self): + def _cancel_clicked(self, e): """Handle cancel button click""" self.result = None - self.dialog.destroy() + self._close_dialog() - def _clear_cache(self): - """Clear all cached items""" - result = messagebox.askyesno( - "Clear Cache", - "Are you sure you want to clear all cached items?\n\n" - "All cached data will be removed.\n" - "The next time you open the app, it will auto-load fresh data." + 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, ) - if result: - try: - # Use cache manager passed to dialog - if self.cache_manager: - self.cache_manager.invalidate_cache() - messagebox.showinfo( - "Cache Cleared", - "All cached items have been cleared.\n" - "Fresh data will be loaded on next app start." - ) - else: - messagebox.showerror("Error", "Cache manager not available") - except Exception as e: - messagebox.showerror("Error", f"Failed to clear cache: {str(e)}") - def show(self) -> Optional[Dict[str, Any]]: - """Show dialog and return result""" - self.dialog.wait_window() - return self.result \ No newline at end of file + 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/application/app_components/settings_manager.py b/application/app_components/settings_manager.py new file mode 100644 index 0000000..c75c12e --- /dev/null +++ b/application/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/application/requirements.txt b/application/requirements.txt index 15c0984..29484f1 100644 --- a/application/requirements.txt +++ b/application/requirements.txt @@ -1,5 +1,9 @@ # Core dependencies requests>=2.31.0 +keyring>=24.0.0 # Secure credential storage + +# UI Framework +flet>=0.23.0 # AI providers (optional - installed automatically when needed) anthropic>=0.18.0 # Claude AI