""" Main GUI Interface The primary user interface for the application """ import tkinter as tk from tkinter import ttk, messagebox, scrolledtext import os import threading import webbrowser from typing import List, Dict, Any, Optional from .utils import Logger from .settings_dialog import SettingsDialog from .work_item_processor import WorkItemProcessor from .azure_devops_api import AzureDevOpsAPI from .dataverse_api import DataverseAPI 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 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 # 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 self.work_item_processor = WorkItemProcessor(self.logger, self.config_manager.get_config()) # 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="MicrosoftDocFlow v3", 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 work item controls section""" # Work Item Details group frame workitem_frame = ttk.LabelFrame(parent, text="๐Ÿ“‹ Work Item Details", style='WorkItem.TLabelframe', padding="15") workitem_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 15), padx=5) workitem_frame.columnconfigure(2, weight=1) # Controls row controls_row = ttk.Frame(workitem_frame) controls_row.grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) controls_row.columnconfigure(6, weight=1) # Fetch buttons self.fetch_button = ttk.Button(controls_row, text="๐Ÿ“ฅ Fetch Work Items", command=self.start_fetch_work_items) self.fetch_button.grid(row=0, column=0, padx=(0, 10)) self.fetch_uuf_button = ttk.Button(controls_row, text="๐Ÿ“‹ Fetch UUF Items", command=self.start_fetch_uuf_items) self.fetch_uuf_button.grid(row=0, column=1, padx=(0, 15)) # Navigation buttons self.prev_button = ttk.Button(controls_row, text="โ† Previous", command=self.previous_item, state='disabled') self.prev_button.grid(row=0, column=2, padx=(0, 5)) self.next_button = ttk.Button(controls_row, text="Next โ†’", command=self.next_item, state='disabled') self.next_button.grid(row=0, column=3, padx=(0, 15)) # Action type dropdown self.action_type_var = tk.StringVar(value="Create Issue") self.action_type_dropdown = ttk.Combobox(controls_row, textvariable=self.action_type_var, values=["Create Issue", "Create PR"], state="readonly", width=15) self.action_type_dropdown.grid(row=0, column=4, padx=(0, 5)) self.action_type_dropdown.bind("<>", lambda e: self.update_action_button_text()) # GO button self.go_button = ttk.Button(controls_row, text="๐Ÿš€ GO", command=self.create_github_resource, state='disabled') self.go_button.grid(row=0, column=5, padx=(0, 20)) # Item counter self.item_counter_label = ttk.Label(controls_row, text="No items loaded", font=('Arial', 9, 'italic')) self.item_counter_label.grid(row=0, column=6, sticky=tk.E) 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: processed_item = self.work_item_processor.process_work_item(item) 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: processed_item = self.work_item_processor.process_uuf_item(item) 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" ) # 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: 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 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') else: self.go_button.config(state='disabled') def _previous_item(self): """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): """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): """Toggle edit mode for proposed new text""" if not self.current_work_items or self.current_item_index >= len(self.current_work_items): 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']}") 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): 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}") 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""" 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() 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')} **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() else: api_key = '' github_token = config.get('GITHUB_PAT', '').strip() local_repo_path = config.get('LOCAL_REPO_PATH', '').strip() or None if not api_key: raise ValueError(f"No API key configured for {ai_provider}. 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) 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""" 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) except Exception as e: self.logger.log(f"Error checking AI provider setup: {str(e)}") 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""" 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}") 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}") 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 _load_diff_file(self, file_path): """Load and display a specific diff file""" 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)}") else: self.logger.log(f"โš ๏ธ Diff file is empty: {file_path}") 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