Updated how settings and API keys are stored - Reworked the GUI to use flet
This commit is contained in:
@@ -208,3 +208,6 @@ __marimo__/
|
|||||||
|
|
||||||
# AI Files
|
# AI Files
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Settings files
|
||||||
|
config.json
|
||||||
+90
-26
@@ -6,8 +6,10 @@ This application provides GitHub automation workflows with AI assistance.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import tkinter as tk
|
import flet as ft
|
||||||
from tkinter import messagebox
|
# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors)
|
||||||
|
ft.icons = ft.Icons
|
||||||
|
ft.colors = ft.Colors
|
||||||
|
|
||||||
# Import our modular components
|
# Import our modular components
|
||||||
try:
|
try:
|
||||||
@@ -24,11 +26,26 @@ except ImportError as e:
|
|||||||
class GitHubAutomationApp:
|
class GitHubAutomationApp:
|
||||||
"""Main application class that orchestrates all components"""
|
"""Main application class that orchestrates all components"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, page: ft.Page):
|
||||||
"""Initialize the application"""
|
"""Initialize the application"""
|
||||||
self.root = tk.Tk()
|
self.page = page
|
||||||
self.root.title("GitHub Pulse")
|
|
||||||
self.root.geometry("1400x1000")
|
# 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
|
# Initialize core managers
|
||||||
self.config_manager = ConfigManager()
|
self.config_manager = ConfigManager()
|
||||||
@@ -41,20 +58,30 @@ class GitHubAutomationApp:
|
|||||||
dry_run_config = self.config.get('DRY_RUN', 'false')
|
dry_run_config = self.config.get('DRY_RUN', 'false')
|
||||||
self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on')
|
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
|
# Initialize main GUI
|
||||||
self.main_gui = MainGUI(
|
self.main_gui = MainGUI(
|
||||||
root=self.root,
|
page=self.page,
|
||||||
config_manager=self.config_manager,
|
config_manager=self.config_manager,
|
||||||
ai_manager=self.ai_manager,
|
ai_manager=self.ai_manager,
|
||||||
app=self
|
app=self
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up AI provider check after GUI is ready
|
# Build UI
|
||||||
self.root.after(100, self._check_ai_provider_setup)
|
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"""
|
"""Check and setup AI providers after GUI initialization"""
|
||||||
try:
|
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()
|
ai_provider = self.config.get('AI_PROVIDER', '').strip().lower()
|
||||||
|
|
||||||
if not ai_provider or ai_provider in ['none', '']:
|
if not ai_provider or ai_provider in ['none', '']:
|
||||||
@@ -64,7 +91,7 @@ class GitHubAutomationApp:
|
|||||||
return # Unknown provider
|
return # Unknown provider
|
||||||
|
|
||||||
# Check if modules are available and offer installation if needed
|
# 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:
|
except Exception as e:
|
||||||
print(f"Error checking AI provider setup: {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
|
logger = self.main_gui.logger if hasattr(self.main_gui, 'logger') else None
|
||||||
return GitHubAPI(token, logger, dry_run)
|
return GitHubAPI(token, logger, dry_run)
|
||||||
|
|
||||||
def run(self):
|
def _on_setting_changed(self, key: str, value: any):
|
||||||
"""Start the application"""
|
"""
|
||||||
try:
|
Handle settings changes with live updates (no restart needed!)
|
||||||
self.root.mainloop()
|
|
||||||
except KeyboardInterrupt:
|
Args:
|
||||||
print("Application interrupted by user")
|
key: Setting key that changed
|
||||||
except Exception as e:
|
value: New value
|
||||||
messagebox.showerror("Application Error", f"An unexpected error occurred:\n{str(e)}")
|
"""
|
||||||
print(f"Application error: {e}")
|
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():
|
async def main(page: ft.Page):
|
||||||
"""Main entry point"""
|
"""Main entry point for Flet application"""
|
||||||
try:
|
try:
|
||||||
app = GitHubAutomationApp()
|
app = GitHubAutomationApp(page)
|
||||||
app.run()
|
|
||||||
except Exception as e:
|
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}")
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
# Run the Flet app
|
||||||
|
ft.app(target=main)
|
||||||
|
|||||||
@@ -3272,6 +3272,26 @@ class AIManager:
|
|||||||
|
|
||||||
return self.install_ai_packages(missing, parent_window)
|
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:
|
def show_ai_modules_info(self, provider_name: str, parent_window=None) -> None:
|
||||||
"""Show detailed AI modules information"""
|
"""Show detailed AI modules information"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,221 +1,135 @@
|
|||||||
"""
|
"""
|
||||||
Configuration Manager
|
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 os
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from .settings_manager import SettingsManager
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
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):
|
def __init__(self):
|
||||||
self.config = self.load_configuration()
|
"""Initialize with SettingsManager backend"""
|
||||||
|
# Initialize the modern settings system
|
||||||
|
self._settings = SettingsManager()
|
||||||
|
|
||||||
def _get_default_config(self) -> Dict[str, Any]:
|
# Check if .env exists and offer migration
|
||||||
"""Get default configuration values"""
|
env_path = Path('.env')
|
||||||
return {
|
if env_path.exists() and not Path('application/config.json').exists():
|
||||||
'GITHUB_PAT': None,
|
print("\n" + "="*60)
|
||||||
'GITHUB_REPO': None,
|
print("NOTICE: Legacy .env file detected!")
|
||||||
'FORKED_REPO': None, # User's fork repository
|
print("="*60)
|
||||||
'AI_PROVIDER': None,
|
print("Your app now uses a modern settings system with:")
|
||||||
'CLAUDE_API_KEY': None,
|
print(" ✓ Secure API key storage (Windows Credential Manager)")
|
||||||
'OPENAI_API_KEY': None,
|
print(" ✓ Live settings updates (no restart needed)")
|
||||||
'GITHUB_TOKEN': None, # For GitHub Copilot AI Provider
|
print(" ✓ Better configuration management")
|
||||||
'OLLAMA_URL': None, # Ollama server URL
|
print()
|
||||||
'OLLAMA_API_KEY': None, # Optional Ollama API key/password
|
print("Migrating settings from .env to new system...")
|
||||||
'OLLAMA_MODEL': None, # Selected Ollama model
|
print()
|
||||||
'LOCAL_REPO_PATH': None,
|
|
||||||
'DRY_RUN': 'false',
|
if self._settings.migrate_from_env(env_path):
|
||||||
'CUSTOM_INSTRUCTIONS': None # Custom AI instructions
|
print("✓ Migration successful!")
|
||||||
}
|
print(f" - Secrets → System keyring")
|
||||||
|
print(f" - Settings → {self._settings.config_file}")
|
||||||
|
print()
|
||||||
|
print("Your .env file is kept as backup.")
|
||||||
|
print("You can delete it once you verify everything works.")
|
||||||
|
else:
|
||||||
|
print("✗ Migration failed. Using .env as fallback.")
|
||||||
|
print("="*60 + "\n")
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
self.config = self._settings.get_all()
|
||||||
|
|
||||||
|
# Auto-default GITHUB_TOKEN to GITHUB_PAT if needed
|
||||||
|
self._apply_token_defaults()
|
||||||
|
|
||||||
|
# Show configuration status
|
||||||
|
self._print_config_status()
|
||||||
|
|
||||||
|
def _apply_token_defaults(self):
|
||||||
|
"""Auto-default GITHUB_TOKEN to GITHUB_PAT if GITHUB_TOKEN is empty"""
|
||||||
|
github_token = self.config.get('GITHUB_TOKEN', '').strip() if self.config.get('GITHUB_TOKEN') else ''
|
||||||
|
github_pat = self.config.get('GITHUB_PAT', '').strip() if self.config.get('GITHUB_PAT') else ''
|
||||||
|
|
||||||
|
if not github_token and github_pat:
|
||||||
|
self.config['GITHUB_TOKEN'] = github_pat
|
||||||
|
self._settings.set('GITHUB_TOKEN', github_pat, save=False)
|
||||||
|
|
||||||
|
def _print_config_status(self):
|
||||||
|
"""Print configuration load status"""
|
||||||
|
loaded_keys = []
|
||||||
|
for key, value in self.config.items():
|
||||||
|
if value and str(value).strip():
|
||||||
|
# Don't show actual secret values
|
||||||
|
if key in SettingsManager.SECRET_KEYS:
|
||||||
|
loaded_keys.append(f"{key}: loaded")
|
||||||
|
else:
|
||||||
|
loaded_keys.append(f"{key}: loaded")
|
||||||
|
|
||||||
|
if loaded_keys:
|
||||||
|
print(f"Configuration status: {', '.join(loaded_keys)}")
|
||||||
|
else:
|
||||||
|
print("No configuration values loaded - using defaults")
|
||||||
|
|
||||||
def load_configuration(self) -> Dict[str, Any]:
|
def load_configuration(self) -> Dict[str, Any]:
|
||||||
"""Load configuration from launch.json first, then .env as fallback"""
|
"""
|
||||||
config = self._get_default_config()
|
Load configuration from new system (config.json + keyring).
|
||||||
launch_json_keys = set()
|
|
||||||
|
|
||||||
# First, try to load from launch.json
|
Returns:
|
||||||
launch_json_path = os.path.join('.vscode', 'launch.json')
|
Dictionary of all settings
|
||||||
if os.path.exists(launch_json_path):
|
"""
|
||||||
try:
|
self.config = self._settings.load()
|
||||||
with open(launch_json_path, 'r', encoding='utf-8') as f:
|
self._apply_token_defaults()
|
||||||
launch_data = json.load(f)
|
return self.config
|
||||||
|
|
||||||
# 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)}")
|
|
||||||
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.
|
|
||||||
|
|
||||||
# GitHub Configuration
|
|
||||||
GITHUB_PAT=
|
|
||||||
GITHUB_REPO=
|
|
||||||
FORKED_REPO=
|
|
||||||
|
|
||||||
# Application Settings
|
|
||||||
DRY_RUN=false
|
|
||||||
|
|
||||||
# AI Provider Configuration (for local PR creation with AI assistance)
|
|
||||||
AI_PROVIDER=
|
|
||||||
CLAUDE_API_KEY=
|
|
||||||
OPENAI_API_KEY=
|
|
||||||
GITHUB_TOKEN=
|
|
||||||
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:
|
def save_configuration(self, config_values: Dict[str, Any]) -> bool:
|
||||||
"""Save configuration to .env file"""
|
"""
|
||||||
try:
|
Save configuration using new system.
|
||||||
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
|
No restart required - changes apply immediately!
|
||||||
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
|
Args:
|
||||||
env_content = []
|
config_values: Settings to save
|
||||||
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("")
|
|
||||||
|
|
||||||
env_content.append("# GitHub Configuration")
|
Returns:
|
||||||
env_content.append(f"GITHUB_PAT={self.config.get('GITHUB_PAT', '')}")
|
True if successful
|
||||||
env_content.append(f"GITHUB_REPO={self.config.get('GITHUB_REPO', '')}")
|
"""
|
||||||
env_content.append(f"FORKED_REPO={self.config.get('FORKED_REPO', '')}")
|
# Save using new system
|
||||||
env_content.append("")
|
success = self._settings.save(config_values)
|
||||||
|
|
||||||
env_content.append("# Application Settings")
|
if success:
|
||||||
dry_run_value = str(self.config.get('DRY_RUN', 'false')).lower()
|
# Reload to get updated values
|
||||||
env_content.append(f"DRY_RUN={dry_run_value}")
|
self.config = self._settings.get_all()
|
||||||
env_content.append("")
|
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")
|
||||||
|
|
||||||
env_content.append("# AI Provider Configuration (for local PR creation with AI assistance)")
|
return success
|
||||||
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("")
|
|
||||||
|
|
||||||
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]:
|
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()
|
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_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 ''
|
github_pat = config.get('GITHUB_PAT', '').strip() if config.get('GITHUB_PAT') else ''
|
||||||
|
|
||||||
@@ -225,18 +139,73 @@ CUSTOM_INSTRUCTIONS=
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
def get_value(self, key: str, default: Any = None) -> Any:
|
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:
|
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:
|
def set_value(self, key: str, value: Any) -> None:
|
||||||
"""Set a specific configuration value"""
|
"""
|
||||||
if key in self.config:
|
Set a specific configuration value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Setting key
|
||||||
|
value: New value
|
||||||
|
"""
|
||||||
|
self._settings.set(key, value)
|
||||||
self.config[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:
|
def get_pr_counter_file(self) -> str:
|
||||||
"""Get the path to the PR counter file"""
|
"""Get the path to the PR counter file"""
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -251,32 +220,27 @@ CUSTOM_INSTRUCTIONS=
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
except (json.JSONDecodeError, FileNotFoundError):
|
except (json.JSONDecodeError, FileNotFoundError):
|
||||||
pass
|
pass
|
||||||
return {}
|
return {'count': 0}
|
||||||
|
|
||||||
def save_pr_counter(self, counter: Dict[str, int]) -> None:
|
def save_pr_counter(self, counter: Dict[str, int]) -> bool:
|
||||||
"""Save the PR counter to file"""
|
"""Save the PR counter to file"""
|
||||||
counter_file = self.get_pr_counter_file()
|
counter_file = self.get_pr_counter_file()
|
||||||
try:
|
try:
|
||||||
# Ensure directory exists
|
|
||||||
os.makedirs(os.path.dirname(counter_file), exist_ok=True)
|
|
||||||
with open(counter_file, 'w', encoding='utf-8') as f:
|
with open(counter_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(counter, f, indent=2)
|
json.dump(counter, f, indent=2)
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not save PR counter: {e}")
|
print(f"Error saving PR counter: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def get_next_pr_number(self, provider_key: str) -> int:
|
def increment_pr_counter(self) -> int:
|
||||||
"""
|
"""Increment and return the PR counter"""
|
||||||
Get the next PR number for a given provider.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider_key: Either the AI provider name ('chatgpt', 'claude') or 'gh_copilot'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The next PR number for this provider
|
|
||||||
"""
|
|
||||||
counter = self.load_pr_counter()
|
counter = self.load_pr_counter()
|
||||||
current_number = counter.get(provider_key, 0)
|
counter['count'] = counter.get('count', 0) + 1
|
||||||
next_number = current_number + 1
|
|
||||||
counter[provider_key] = next_number
|
|
||||||
self.save_pr_counter(counter)
|
self.save_pr_counter(counter)
|
||||||
return next_number
|
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)
|
||||||
|
|||||||
+1060
-2804
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
keyring>=24.0.0 # Secure credential storage
|
||||||
|
|
||||||
|
# UI Framework
|
||||||
|
flet>=0.23.0
|
||||||
|
|
||||||
# AI providers (optional - installed automatically when needed)
|
# AI providers (optional - installed automatically when needed)
|
||||||
anthropic>=0.18.0 # Claude AI
|
anthropic>=0.18.0 # Claude AI
|
||||||
|
|||||||
Reference in New Issue
Block a user