Refactor GitHub automation tool:
- Updated WorkItemFieldExtractor to be more generic and removed Azure DevOps specific references. - Removed the EnhancedContentBuilders class as it was specific to Azure DevOps. - Deleted work_item_processor.py as it was no longer needed. - Introduced workflow.py to manage GitHub workflow items (issues and pull requests) with improved structure and functionality. - Enhanced logging and error handling across the new workflow management system.
This commit is contained in:
+23
-25
@@ -1,9 +1,8 @@
|
||||
"""
|
||||
MicrosoftDocFlow v3
|
||||
GitHub Automation Tool
|
||||
Main application entry point
|
||||
|
||||
This application processes Azure DevOps work items and UUF items,
|
||||
creating GitHub issues or pull requests with AI assistance.
|
||||
This application provides GitHub automation workflows with AI assistance.
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -15,7 +14,6 @@ try:
|
||||
from app_components.config_manager import ConfigManager
|
||||
from app_components.ai_manager import AIManager
|
||||
from app_components.github_api import GitHubAPI
|
||||
from app_components.utils import Logger, PRNumberManager, ContentBuilders
|
||||
from app_components.main_gui import MainGUI
|
||||
except ImportError as e:
|
||||
print(f"Error importing application components: {e}")
|
||||
@@ -23,26 +21,26 @@ except ImportError as e:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class AzureDevOpsToGitHubApp:
|
||||
class GitHubAutomationApp:
|
||||
"""Main application class that orchestrates all components"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the application"""
|
||||
self.root = tk.Tk()
|
||||
self.root.title("MicrosoftDocFlow v3")
|
||||
self.root.title("GitHub Automation Tool")
|
||||
self.root.geometry("1400x1000")
|
||||
|
||||
|
||||
# Initialize core managers
|
||||
self.config_manager = ConfigManager()
|
||||
self.ai_manager = AIManager()
|
||||
|
||||
|
||||
# Load configuration
|
||||
self.config = self.config_manager.load_configuration()
|
||||
|
||||
|
||||
# Initialize dry run state
|
||||
dry_run_config = self.config.get('DRY_RUN', 'false')
|
||||
self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
|
||||
# Initialize main GUI
|
||||
self.main_gui = MainGUI(
|
||||
root=self.root,
|
||||
@@ -50,36 +48,36 @@ class AzureDevOpsToGitHubApp:
|
||||
ai_manager=self.ai_manager,
|
||||
app=self
|
||||
)
|
||||
|
||||
|
||||
# Set up AI provider check after GUI is ready
|
||||
self.root.after(100, self._check_ai_provider_setup)
|
||||
|
||||
|
||||
def _check_ai_provider_setup(self):
|
||||
"""Check and setup AI providers after GUI initialization"""
|
||||
try:
|
||||
ai_provider = self.config.get('AI_PROVIDER', '').strip().lower()
|
||||
|
||||
|
||||
if not ai_provider or ai_provider in ['none', '']:
|
||||
return # No AI provider selected
|
||||
|
||||
|
||||
if ai_provider not in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot']:
|
||||
return # Unknown provider
|
||||
|
||||
|
||||
# Check if modules are available and offer installation if needed
|
||||
self.ai_manager.check_and_install_ai_modules(ai_provider, self.root)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking AI provider setup: {e}")
|
||||
|
||||
|
||||
def get_config(self):
|
||||
"""Get current configuration"""
|
||||
return self.config.copy()
|
||||
|
||||
|
||||
def update_config(self, new_config):
|
||||
"""Update configuration"""
|
||||
self.config.update(new_config)
|
||||
self.config_manager.config = self.config.copy()
|
||||
|
||||
|
||||
def save_config(self, config_values):
|
||||
"""Save configuration"""
|
||||
success = self.config_manager.save_configuration(config_values)
|
||||
@@ -89,17 +87,17 @@ class AzureDevOpsToGitHubApp:
|
||||
dry_run_config = self.config.get('DRY_RUN', 'false')
|
||||
self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on')
|
||||
return success
|
||||
|
||||
|
||||
def create_github_api(self, token=None, dry_run=None):
|
||||
"""Create a GitHub API instance"""
|
||||
if token is None:
|
||||
token = self.config.get('GITHUB_PAT', '')
|
||||
if dry_run is None:
|
||||
dry_run = self.dry_run_enabled
|
||||
|
||||
|
||||
logger = self.main_gui.logger if hasattr(self.main_gui, 'logger') else None
|
||||
return GitHubAPI(token, logger, dry_run)
|
||||
|
||||
|
||||
def run(self):
|
||||
"""Start the application"""
|
||||
try:
|
||||
@@ -114,7 +112,7 @@ class AzureDevOpsToGitHubApp:
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
try:
|
||||
app = AzureDevOpsToGitHubApp()
|
||||
app = GitHubAutomationApp()
|
||||
app.run()
|
||||
except Exception as e:
|
||||
print(f"Failed to start application: {e}")
|
||||
@@ -122,4 +120,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
"""
|
||||
Azure DevOps & UUF → GitHub Processor - Application Components
|
||||
GitHub Pulse - Application Components
|
||||
Modular components for the application
|
||||
"""
|
||||
|
||||
# Version info
|
||||
__version__ = "3.0.0"
|
||||
__author__ = "Azure DevOps to GitHub Processor"
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "GitHub Pulse"
|
||||
|
||||
# Export main classes for easier imports
|
||||
from .config_manager import ConfigManager
|
||||
from .ai_manager import AIManager
|
||||
from .github_api import GitHubAPI
|
||||
from .azure_devops_api import AzureDevOpsAPI
|
||||
from .dataverse_api import DataverseAPI
|
||||
from .work_item_processor import WorkItemProcessor
|
||||
from .settings_dialog import SettingsDialog
|
||||
from .main_gui import MainGUI
|
||||
from .utils import Logger, PRNumberManager, ContentBuilders
|
||||
from .workflow import WorkflowManager, WorkflowItem, GitHubRepoFetcher
|
||||
|
||||
__all__ = [
|
||||
'ConfigManager',
|
||||
'AIManager',
|
||||
'AIManager',
|
||||
'GitHubAPI',
|
||||
'AzureDevOpsAPI',
|
||||
'DataverseAPI',
|
||||
'WorkItemProcessor',
|
||||
'SettingsDialog',
|
||||
'MainGUI',
|
||||
'Logger',
|
||||
'PRNumberManager',
|
||||
'ContentBuilders'
|
||||
]
|
||||
'ContentBuilders',
|
||||
'WorkflowManager',
|
||||
'WorkflowItem',
|
||||
'GitHubRepoFetcher'
|
||||
]
|
||||
|
||||
@@ -2709,7 +2709,7 @@ class LocalGitManager:
|
||||
self.logger.log(f"❌ Error saving diff to file: {str(e)}")
|
||||
|
||||
|
||||
def create_ai_provider(provider_name: str, api_key: str, logger: Logger) -> Optional[AIProvider]:
|
||||
def create_ai_provider(provider_name: str, api_key: str, logger: Logger, ollama_url: str = None, ollama_model: str = None) -> Optional[AIProvider]:
|
||||
"""Factory function to create AI provider instances"""
|
||||
if provider_name.lower() == 'claude':
|
||||
return ClaudeProvider(api_key, logger)
|
||||
@@ -2717,6 +2717,9 @@ def create_ai_provider(provider_name: str, api_key: str, logger: Logger) -> Opti
|
||||
return ChatGPTProvider(api_key, logger)
|
||||
elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']:
|
||||
return GitHubCopilotProvider(api_key, logger)
|
||||
elif provider_name.lower() == 'ollama':
|
||||
# For Ollama, api_key is optional (can be empty string)
|
||||
return OllamaProvider(api_key or "", logger, ollama_url, ollama_model)
|
||||
else:
|
||||
logger.log(f"⚠️ Unknown AI provider: {provider_name}")
|
||||
return None
|
||||
@@ -2927,6 +2930,198 @@ def validate_ai_provider_setup(config: dict, parent_window=None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class OllamaProvider(AIProvider):
|
||||
"""Ollama AI provider for self-hosted models"""
|
||||
|
||||
def __init__(self, api_key: str, logger: Logger, ollama_url: str = None, model: str = None):
|
||||
super().__init__(api_key, logger)
|
||||
self.ollama_url = ollama_url or "http://localhost:11434"
|
||||
self.model = model or "llama2"
|
||||
|
||||
# Normalize URL
|
||||
if not self.ollama_url.startswith('http'):
|
||||
self.ollama_url = f"http://{self.ollama_url}"
|
||||
|
||||
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
|
||||
"""Make targeted changes using Ollama"""
|
||||
|
||||
# Step 1: Try direct string replacement first
|
||||
if old_text and old_text.strip() in file_content:
|
||||
self.logger.log("✅ Making direct string replacement (reference text found exactly)")
|
||||
updated_content = file_content.replace(old_text.strip(), new_text.strip())
|
||||
if updated_content != file_content:
|
||||
original_lines = file_content.split('\n')
|
||||
updated_lines = updated_content.split('\n')
|
||||
import difflib
|
||||
diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm=''))
|
||||
changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')])
|
||||
self.logger.log(f"✅ Direct replacement successful ({changed_lines} lines changed)")
|
||||
return updated_content
|
||||
|
||||
# Step 2: Use Ollama to generate full document with targeted changes
|
||||
self.logger.log(f"📝 Using Ollama ({self.model}) to modify the document...")
|
||||
return self._generate_updated_document(file_content, old_text, new_text, file_path, custom_instructions)
|
||||
|
||||
def _generate_updated_document(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
|
||||
"""Generate updated document content using Ollama"""
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Build custom instructions text
|
||||
if custom_instructions and custom_instructions.strip():
|
||||
custom_instructions_text = f"""
|
||||
**Additional Custom Instructions:**
|
||||
{custom_instructions.strip()}
|
||||
|
||||
"""
|
||||
else:
|
||||
custom_instructions_text = ""
|
||||
|
||||
# Handle case where new_text is empty or just guidance
|
||||
if new_text and new_text.strip() and not new_text.strip().lower().startswith('<blank'):
|
||||
# We have specific replacement text
|
||||
guidance_text = f"""
|
||||
**Reference text to find:**
|
||||
```
|
||||
{old_text}
|
||||
```
|
||||
|
||||
**Replace with this specific content:**
|
||||
```
|
||||
{new_text}
|
||||
```
|
||||
|
||||
Please find the reference text and replace it with the suggested content."""
|
||||
else:
|
||||
# new_text is empty or just guidance - use old_text as instructions
|
||||
guidance_text = f"""
|
||||
**Task Instructions:**
|
||||
{old_text}
|
||||
|
||||
**Note:** No specific replacement text provided. Use the task instructions above to determine what changes to make to improve the document. Add appropriate content based on the instructions."""
|
||||
|
||||
prompt = f"""**Instructions:**
|
||||
|
||||
Task: Update the documentation file with the changes requested.
|
||||
|
||||
Steps to complete:
|
||||
|
||||
1. Review the current file content below
|
||||
2. Follow the guidance provided to determine what changes to make
|
||||
3. Make appropriate improvements while maintaining existing formatting
|
||||
4. Return the complete updated file content
|
||||
|
||||
> [!IMPORTANT]
|
||||
> OUTPUT REQUIREMENTS:
|
||||
> - Return ONLY the complete file content - no explanatory text, dialog, or commentary
|
||||
> - Do NOT add any text before or after the file content
|
||||
> - Do NOT wrap output in markdown code blocks (```), just return the raw content
|
||||
> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...]
|
||||
> - Every single line of the original document must be present in your response
|
||||
> - Preserve all markdown formatting, links, and code blocks exactly
|
||||
> - Only make changes that fulfill the specified request
|
||||
|
||||
{custom_instructions_text}
|
||||
|
||||
**Current File Content:**
|
||||
```
|
||||
{file_content}
|
||||
```
|
||||
|
||||
{guidance_text}
|
||||
|
||||
Return the complete updated file content now (NO explanatory text):"""
|
||||
|
||||
# Prepare request headers
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
# Prepare request payload
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3, # Lower temperature for more consistent output
|
||||
"num_predict": -1, # Generate as many tokens as needed
|
||||
}
|
||||
}
|
||||
|
||||
# Make request to Ollama
|
||||
self.logger.log(f"🔄 Sending request to Ollama at {self.ollama_url}...")
|
||||
response = requests.post(
|
||||
f"{self.ollama_url}/api/generate",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=300 # 5 minute timeout for large documents
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
updated_content = result.get("response", "").strip()
|
||||
|
||||
if not updated_content:
|
||||
self.logger.log("❌ Ollama returned empty response")
|
||||
return None
|
||||
|
||||
# Clean up response
|
||||
updated_content = self._clean_ai_response(updated_content)
|
||||
|
||||
# Validate that we got the full document back
|
||||
original_line_count = len(file_content.split('\n'))
|
||||
updated_line_count = len(updated_content.split('\n'))
|
||||
|
||||
if updated_line_count < original_line_count * 0.5: # Less than 50% of original lines
|
||||
self.logger.log(f"⚠️ Warning: Updated document seems truncated ({updated_line_count} vs {original_line_count} lines)")
|
||||
self.logger.log("❌ AI may have truncated the document - using fallback")
|
||||
return None
|
||||
|
||||
self.logger.log(f"✅ Successfully generated updated document ({updated_line_count} lines)")
|
||||
return updated_content
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.logger.log(f"❌ Could not connect to Ollama server at {self.ollama_url}")
|
||||
self.logger.log(" Make sure Ollama is running and the URL is correct")
|
||||
return None
|
||||
except requests.exceptions.Timeout:
|
||||
self.logger.log("❌ Request to Ollama server timed out")
|
||||
return None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
self.logger.log("❌ Authentication failed - check your Ollama API key")
|
||||
elif e.response.status_code == 404:
|
||||
self.logger.log(f"❌ Model '{self.model}' not found on Ollama server")
|
||||
self.logger.log(f" Use 'ollama pull {self.model}' to download it")
|
||||
else:
|
||||
self.logger.log(f"❌ HTTP error from Ollama: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.log(f"❌ Error calling Ollama: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _clean_ai_response(self, response: str) -> str:
|
||||
"""Clean up AI response by removing markdown code blocks and explanatory text"""
|
||||
|
||||
# Remove markdown code blocks if present
|
||||
if response.startswith('```'):
|
||||
lines = response.split('\n')
|
||||
# Remove first line if it's a code fence
|
||||
if lines[0].startswith('```'):
|
||||
lines = lines[1:]
|
||||
# Remove last line if it's a code fence
|
||||
if lines and lines[-1].strip() == '```':
|
||||
lines = lines[:-1]
|
||||
response = '\n'.join(lines)
|
||||
|
||||
return response.strip()
|
||||
|
||||
|
||||
# AI Providers availability flag - now always True since they're included
|
||||
AI_PROVIDERS_AVAILABLE = True
|
||||
|
||||
@@ -2952,18 +3147,18 @@ class AIManager:
|
||||
|
||||
def check_ai_module_availability(self, provider_name: str) -> Tuple[bool, List[str]]:
|
||||
"""Check if AI provider modules are available and return missing packages
|
||||
|
||||
|
||||
Args:
|
||||
provider_name: 'chatgpt', 'claude', 'anthropic', or 'github-copilot'
|
||||
|
||||
provider_name: 'chatgpt', 'claude', 'anthropic', 'github-copilot', or 'ollama'
|
||||
|
||||
Returns:
|
||||
tuple: (all_available, missing_packages)
|
||||
"""
|
||||
missing_packages = []
|
||||
|
||||
|
||||
# Common packages needed for AI providers
|
||||
required_common = ['GitPython']
|
||||
|
||||
|
||||
# Provider-specific packages
|
||||
if provider_name.lower() == 'chatgpt':
|
||||
required_packages = required_common + ['openai']
|
||||
@@ -2971,9 +3166,11 @@ class AIManager:
|
||||
required_packages = required_common + ['anthropic']
|
||||
elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']:
|
||||
required_packages = required_common + ['requests']
|
||||
elif provider_name.lower() == 'ollama':
|
||||
required_packages = required_common + ['requests']
|
||||
else:
|
||||
return True, [] # Unknown provider, assume no check needed
|
||||
|
||||
|
||||
for package in required_packages:
|
||||
try:
|
||||
if package == 'GitPython':
|
||||
@@ -2982,9 +3179,11 @@ class AIManager:
|
||||
import openai
|
||||
elif package == 'anthropic':
|
||||
import anthropic
|
||||
elif package == 'requests':
|
||||
import requests
|
||||
except ImportError:
|
||||
missing_packages.append(package)
|
||||
|
||||
|
||||
all_available = len(missing_packages) == 0
|
||||
return all_available, missing_packages
|
||||
|
||||
@@ -3159,14 +3358,14 @@ class AIManager:
|
||||
if self.logger:
|
||||
self.logger.log(f"Error in AI modules check: {str(e)}")
|
||||
|
||||
def create_ai_provider(self, provider_name: str, api_key: str):
|
||||
def create_ai_provider(self, provider_name: str, api_key: str, ollama_url: str = None, ollama_model: str = None):
|
||||
"""Create an AI provider instance"""
|
||||
if not AI_PROVIDERS_AVAILABLE:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
ai_logger = Logger(self.log)
|
||||
return create_ai_provider(provider_name, api_key, ai_logger)
|
||||
return create_ai_provider(provider_name, api_key, ai_logger, ollama_url, ollama_model)
|
||||
except Exception as e:
|
||||
self.log(f"Error creating AI provider: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"""
|
||||
Azure DevOps API Manager
|
||||
Handles Azure DevOps REST API operations for work items
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
# User agent for Azure DevOps API requests
|
||||
USER_AGENT = "azure-devops-github-processor/2.0"
|
||||
|
||||
|
||||
class AzureDevOpsAPI:
|
||||
"""Azure DevOps REST API client"""
|
||||
|
||||
def __init__(self, organization: str, pat_token: str, logger=None):
|
||||
self.organization = organization
|
||||
self.pat_token = pat_token
|
||||
self.logger = logger
|
||||
self.base_url = f"https://dev.azure.com/{organization}"
|
||||
self.api_version = "7.0"
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
"""Log a message"""
|
||||
if self.logger:
|
||||
self.logger.log(message)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
def _headers(self):
|
||||
"""Get headers for Azure DevOps API requests"""
|
||||
return {
|
||||
"Authorization": f"Basic {base64.b64encode(f':{self.pat_token}'.encode()).decode()}",
|
||||
"Content-Type": "application/json-patch+json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
def parse_query_url(self, url: str) -> Tuple[str, str, str]:
|
||||
"""Parse Azure DevOps query URL to extract org, project, and query ID
|
||||
|
||||
Supports both URL formats:
|
||||
1. https://dev.azure.com/organization/project/_queries/query/12345/
|
||||
2. https://organization.visualstudio.com/project/_queries/query/12345/
|
||||
"""
|
||||
parsed_url = urlparse(url)
|
||||
|
||||
# Check for dev.azure.com format
|
||||
if 'dev.azure.com' in parsed_url.netloc:
|
||||
path_parts = parsed_url.path.strip('/').split('/')
|
||||
|
||||
if len(path_parts) < 5:
|
||||
raise ValueError("Invalid query URL format for dev.azure.com")
|
||||
|
||||
organization = path_parts[0]
|
||||
project = path_parts[1]
|
||||
|
||||
# Check for visualstudio.com format
|
||||
elif 'visualstudio.com' in parsed_url.netloc:
|
||||
# Extract organization from subdomain (e.g., msft-skilling.visualstudio.com)
|
||||
hostname_parts = parsed_url.netloc.split('.')
|
||||
if len(hostname_parts) < 3 or hostname_parts[1] != 'visualstudio':
|
||||
raise ValueError("Invalid visualstudio.com URL format")
|
||||
|
||||
organization = hostname_parts[0]
|
||||
|
||||
path_parts = parsed_url.path.strip('/').split('/')
|
||||
|
||||
if len(path_parts) < 4:
|
||||
raise ValueError("Invalid query URL format for visualstudio.com")
|
||||
|
||||
project = path_parts[0]
|
||||
|
||||
else:
|
||||
raise ValueError("URL must be from dev.azure.com or visualstudio.com")
|
||||
|
||||
# Find query ID in the URL (same logic for both formats)
|
||||
query_id = None
|
||||
if '_queries/query/' in url:
|
||||
# Extract query ID from path
|
||||
for i, part in enumerate(path_parts):
|
||||
if part == 'query' and i > 0 and path_parts[i-1] == '_queries':
|
||||
if i + 1 < len(path_parts):
|
||||
query_id = path_parts[i + 1]
|
||||
break
|
||||
elif 'queryId=' in url:
|
||||
match = re.search(r'queryId=([^&]+)', url)
|
||||
if match:
|
||||
query_id = match.group(1)
|
||||
|
||||
if not query_id:
|
||||
raise ValueError("Could not extract query ID from URL")
|
||||
|
||||
return organization, project, query_id
|
||||
|
||||
def execute_query(self, org: str, project: str, query_id: str, token: str) -> List[Dict[str, Any]]:
|
||||
"""Execute Azure DevOps query and return work items"""
|
||||
# Build API URL for query execution
|
||||
api_url = f"https://dev.azure.com/{org}/{project}/_apis/wit/wiql/{query_id}?api-version=6.0"
|
||||
|
||||
# Prepare headers
|
||||
auth_string = base64.b64encode(f":{token}".encode()).decode()
|
||||
headers = {
|
||||
'Authorization': f'Basic {auth_string}',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
|
||||
# Execute query
|
||||
self.log(f"Executing query at: {api_url}")
|
||||
response = requests.get(api_url, headers=headers, timeout=30)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"Query execution failed: {response.status_code} - {response.text}")
|
||||
|
||||
query_result = response.json()
|
||||
work_item_refs = query_result.get('workItems', [])
|
||||
|
||||
if not work_item_refs:
|
||||
self.log("No work items found in query result")
|
||||
return []
|
||||
|
||||
# Get detailed work item data
|
||||
work_item_ids = [str(item['id']) for item in work_item_refs]
|
||||
ids_param = ','.join(work_item_ids)
|
||||
|
||||
details_url = f"https://dev.azure.com/{org}/{project}/_apis/wit/workitems?ids={ids_param}&api-version=6.0"
|
||||
|
||||
self.log(f"Fetching details for {len(work_item_ids)} work items")
|
||||
details_response = requests.get(details_url, headers=headers, timeout=30)
|
||||
|
||||
if details_response.status_code != 200:
|
||||
raise RuntimeError(f"Work item details fetch failed: {details_response.status_code}")
|
||||
|
||||
return details_response.json().get('value', [])
|
||||
|
||||
def _get_work_items_details(self, organization: str, work_item_ids: List[str], pat_token: str) -> List[Dict[str, Any]]:
|
||||
"""Get detailed information for work items"""
|
||||
try:
|
||||
# Build batch request URL
|
||||
ids_param = ','.join(work_item_ids)
|
||||
details_url = f"https://dev.azure.com/{organization}/_apis/wit/workitems"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {self._encode_pat(pat_token)}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
params = {
|
||||
"ids": ids_param,
|
||||
"api-version": self.api_version,
|
||||
"$expand": "fields"
|
||||
}
|
||||
|
||||
self.log(f"Fetching details for work items: {ids_param}")
|
||||
response = requests.get(details_url, headers=headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
work_items = result.get('value', [])
|
||||
|
||||
self.log(f"Retrieved details for {len(work_items)} work item(s)")
|
||||
return work_items
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise Exception(f"Network error fetching work item details: {str(e)}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching work item details: {str(e)}")
|
||||
|
||||
def add_github_link_to_work_item(self, work_item_id: str, github_url: str, link_title: str = "GitHub Issue"):
|
||||
"""Add a GitHub issue/PR link to an Azure DevOps work item"""
|
||||
self.log(f"Adding GitHub link to work item #{work_item_id}: {github_url}")
|
||||
|
||||
url = f"{self.base_url}/_apis/wit/workitems/{work_item_id}?api-version=7.0"
|
||||
|
||||
patch_document = [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/relations/-",
|
||||
"value": {
|
||||
"rel": "Hyperlink",
|
||||
"url": github_url,
|
||||
"attributes": {
|
||||
"comment": link_title
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
response = requests.patch(url, headers=self._headers(), json=patch_document, timeout=30)
|
||||
if response.status_code == 200:
|
||||
self.log(f"✅ Successfully linked GitHub resource to work item #{work_item_id}")
|
||||
return True
|
||||
else:
|
||||
self.log(f"❌ Failed to link GitHub resource: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"❌ Exception linking GitHub resource: {str(e)}")
|
||||
return False
|
||||
|
||||
def _encode_pat(self, pat_token: str) -> str:
|
||||
"""Encode PAT token for Basic authentication"""
|
||||
import base64
|
||||
# For Azure DevOps, username can be empty, just use :token
|
||||
credentials = f":{pat_token}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
return encoded
|
||||
@@ -17,8 +17,6 @@ class ConfigManager:
|
||||
def _get_default_config(self) -> Dict[str, Any]:
|
||||
"""Get default configuration values"""
|
||||
return {
|
||||
'AZURE_DEVOPS_QUERY': None,
|
||||
'AZURE_DEVOPS_PAT': None,
|
||||
'GITHUB_PAT': None,
|
||||
'GITHUB_REPO': None,
|
||||
'FORKED_REPO': None, # User's fork repository
|
||||
@@ -26,13 +24,11 @@ class ConfigManager:
|
||||
'CLAUDE_API_KEY': None,
|
||||
'OPENAI_API_KEY': None,
|
||||
'GITHUB_TOKEN': None, # For GitHub Copilot AI Provider
|
||||
'OLLAMA_URL': None, # Ollama server URL
|
||||
'OLLAMA_API_KEY': None, # Optional Ollama API key/password
|
||||
'OLLAMA_MODEL': None, # Selected Ollama model
|
||||
'LOCAL_REPO_PATH': None,
|
||||
'DRY_RUN': 'false',
|
||||
'DATAVERSE_ENVIRONMENT_URL': None,
|
||||
'DATAVERSE_TABLE_NAME': None,
|
||||
'AZURE_AD_CLIENT_ID': None,
|
||||
'AZURE_AD_CLIENT_SECRET': None,
|
||||
'AZURE_AD_TENANT_ID': None,
|
||||
'CUSTOM_INSTRUCTIONS': None # Custom AI instructions
|
||||
}
|
||||
|
||||
@@ -120,14 +116,10 @@ class ConfigManager:
|
||||
def _create_default_env_file(self, config: Dict[str, Any]) -> None:
|
||||
"""Create a default .env file with empty values"""
|
||||
try:
|
||||
env_template = """# Azure DevOps to GitHub Tool Configuration
|
||||
env_template = """# GitHub Pulse Configuration
|
||||
# Generated automatically - fill in your values
|
||||
# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore.
|
||||
|
||||
# Azure DevOps Configuration
|
||||
AZURE_DEVOPS_QUERY=
|
||||
AZURE_DEVOPS_PAT=
|
||||
|
||||
# GitHub Configuration
|
||||
GITHUB_PAT=
|
||||
GITHUB_REPO=
|
||||
@@ -141,24 +133,20 @@ AI_PROVIDER=
|
||||
CLAUDE_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GITHUB_TOKEN=
|
||||
OLLAMA_URL=
|
||||
OLLAMA_API_KEY=
|
||||
OLLAMA_MODEL=
|
||||
LOCAL_REPO_PATH=
|
||||
|
||||
# PowerApp/Dataverse Configuration (for UUF items - optional)
|
||||
DATAVERSE_ENVIRONMENT_URL=
|
||||
DATAVERSE_TABLE_NAME=
|
||||
AZURE_AD_CLIENT_ID=
|
||||
AZURE_AD_CLIENT_SECRET=
|
||||
AZURE_AD_TENANT_ID=
|
||||
|
||||
# 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}")
|
||||
|
||||
@@ -179,27 +167,22 @@ CUSTOM_INSTRUCTIONS=
|
||||
|
||||
# Build .env file content
|
||||
env_content = []
|
||||
env_content.append("# Azure DevOps to GitHub Tool Configuration")
|
||||
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("# Azure DevOps Configuration")
|
||||
env_content.append(f"AZURE_DEVOPS_QUERY={self.config.get('AZURE_DEVOPS_QUERY', '')}")
|
||||
env_content.append(f"AZURE_DEVOPS_PAT={self.config.get('AZURE_DEVOPS_PAT', '')}")
|
||||
env_content.append("")
|
||||
|
||||
|
||||
env_content.append("# GitHub Configuration")
|
||||
env_content.append(f"GITHUB_PAT={self.config.get('GITHUB_PAT', '')}")
|
||||
env_content.append(f"GITHUB_REPO={self.config.get('GITHUB_REPO', '')}")
|
||||
env_content.append(f"FORKED_REPO={self.config.get('FORKED_REPO', '')}")
|
||||
env_content.append("")
|
||||
|
||||
|
||||
env_content.append("# Application Settings")
|
||||
dry_run_value = str(self.config.get('DRY_RUN', 'false')).lower()
|
||||
env_content.append(f"DRY_RUN={dry_run_value}")
|
||||
env_content.append("")
|
||||
|
||||
|
||||
env_content.append("# AI Provider Configuration (for local PR creation with AI assistance)")
|
||||
ai_provider_value = self.config.get('AI_PROVIDER', '')
|
||||
print(f"DEBUG: Writing AI_PROVIDER to file: '{ai_provider_value}'")
|
||||
@@ -207,17 +190,12 @@ CUSTOM_INSTRUCTIONS=
|
||||
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("# PowerApp/Dataverse Configuration (for UUF items - optional)")
|
||||
env_content.append(f"DATAVERSE_ENVIRONMENT_URL={self.config.get('DATAVERSE_ENVIRONMENT_URL', '')}")
|
||||
env_content.append(f"DATAVERSE_TABLE_NAME={self.config.get('DATAVERSE_TABLE_NAME', '')}")
|
||||
env_content.append(f"AZURE_AD_CLIENT_ID={self.config.get('AZURE_AD_CLIENT_ID', '')}")
|
||||
env_content.append(f"AZURE_AD_CLIENT_SECRET={self.config.get('AZURE_AD_CLIENT_SECRET', '')}")
|
||||
env_content.append(f"AZURE_AD_TENANT_ID={self.config.get('AZURE_AD_TENANT_ID', '')}")
|
||||
env_content.append("")
|
||||
|
||||
|
||||
env_content.append("# Custom AI Instructions (optional)")
|
||||
env_content.append(f"CUSTOM_INSTRUCTIONS={self.config.get('CUSTOM_INSTRUCTIONS', '')}")
|
||||
env_content.append("")
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
"""
|
||||
Dataverse API Manager
|
||||
Handles PowerApp/Dataverse operations for UUF items
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
import urllib.parse
|
||||
from typing import List, Dict, Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Constants
|
||||
USER_AGENT = "azure-devops-github-processor/2.0"
|
||||
|
||||
|
||||
class DataverseAPI:
|
||||
"""Dataverse/PowerApp API client for UUF items"""
|
||||
|
||||
def __init__(self, environment_url: str, table_name: str, logger=None, config: dict = None):
|
||||
self.environment_url = environment_url.rstrip('/')
|
||||
self.table_name = table_name
|
||||
self.logger = logger
|
||||
self.config = config or {}
|
||||
self.access_token = None
|
||||
self.api_version = "v9.2"
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
"""Log a message"""
|
||||
if self.logger:
|
||||
self.logger.log(message)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
def authenticate(self, client_id: str, client_secret: str, tenant_id: str) -> bool:
|
||||
"""Authenticate with Azure AD and get access token"""
|
||||
try:
|
||||
# Azure AD token endpoint
|
||||
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
|
||||
# Prepare request data
|
||||
data = {
|
||||
'grant_type': 'client_credentials',
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret,
|
||||
'scope': f"{self.environment_url}/.default"
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
self.log("Authenticating with Azure AD...")
|
||||
response = requests.post(token_url, data=data, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self.access_token = token_data['access_token']
|
||||
|
||||
self.log("✅ Successfully authenticated with Azure AD")
|
||||
return True
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.log(f"❌ Network error during authentication: {str(e)}")
|
||||
return False
|
||||
except KeyError as e:
|
||||
self.log(f"❌ Invalid token response: {str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"❌ Authentication error: {str(e)}")
|
||||
return False
|
||||
|
||||
def _headers(self):
|
||||
"""Get headers for Dataverse API requests"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"OData-MaxVersion": "4.0",
|
||||
"OData-Version": "4.0",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
def fetch_uuf_items(self, filter_query: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Fetch UUF items from Dataverse"""
|
||||
try:
|
||||
if not self.access_token:
|
||||
raise RuntimeError("Not authenticated. Call authenticate() first.")
|
||||
|
||||
self.log(f"Fetching UUF items from table: {self.table_name}")
|
||||
|
||||
# Build API URL
|
||||
api_url = f"{self.environment_url}/api/data/{self.api_version}/{self.table_name}"
|
||||
|
||||
# Add filter if provided
|
||||
if filter_query:
|
||||
api_url += f"?$filter={urllib.parse.quote(filter_query)}"
|
||||
|
||||
response = requests.get(api_url, headers=self._headers(), timeout=60)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"Failed to fetch UUF items: {response.status_code} - {response.text}")
|
||||
|
||||
data = response.json()
|
||||
items = data.get('value', [])
|
||||
|
||||
self.log(f"✅ Fetched {len(items)} UUF items from Dataverse")
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error fetching UUF items: {str(e)}")
|
||||
raise
|
||||
|
||||
def process_uuf_item(self, uuf_item: dict) -> dict | None:
|
||||
"""Process a single UUF item from Dataverse/PowerApp
|
||||
|
||||
UUF items may have different field names than Azure DevOps work items.
|
||||
Adjust the field mapping based on your actual Dataverse table schema.
|
||||
"""
|
||||
try:
|
||||
# Extract UUF item ID (adjust field names as needed)
|
||||
uuf_id = uuf_item.get('cr4af_uufid') or uuf_item.get('cr4af_name') or uuf_item.get('cr_uufitemid') or 'unknown'
|
||||
|
||||
# Extract title
|
||||
title = uuf_item.get('cr4af_title') or uuf_item.get('cr4af_subject') or uuf_item.get('cr_title') or 'No Title'
|
||||
|
||||
# Extract description/details
|
||||
description = uuf_item.get('cr4af_description') or uuf_item.get('cr4af_details') or uuf_item.get('cr_description') or ''
|
||||
|
||||
if not description:
|
||||
self.log(f"UUF item {uuf_id} has no description, skipping")
|
||||
return None
|
||||
|
||||
# Extract document URL
|
||||
doc_url = uuf_item.get('cr4af_documenturl') or uuf_item.get('cr4af_docurl') or uuf_item.get('cr_documenturl') or ''
|
||||
|
||||
if not doc_url:
|
||||
self.log(f"UUF item {uuf_id} has no document URL, skipping")
|
||||
return None
|
||||
|
||||
# Extract text to change and new text
|
||||
text_to_change = uuf_item.get('cr4af_texttochange') or uuf_item.get('cr4af_currenttext') or uuf_item.get('cr_currenttext') or ''
|
||||
new_text = uuf_item.get('cr4af_proposednewtext') or uuf_item.get('cr4af_newtext') or uuf_item.get('cr_newtext') or ''
|
||||
|
||||
if not text_to_change or not new_text:
|
||||
self.log(f"UUF item {uuf_id} missing text fields, skipping")
|
||||
return None
|
||||
|
||||
# Extract GitHub info from document URL
|
||||
github_info = self._extract_github_info(doc_url)
|
||||
|
||||
# If the document does not include an original_content_git_url, skip this item
|
||||
if not github_info.get('original_content_git_url'):
|
||||
self.log(f"UUF item {uuf_id} skipped: original_content_git_url not found in document {doc_url}")
|
||||
return None
|
||||
|
||||
processed_item = {
|
||||
'id': uuf_id,
|
||||
'title': title,
|
||||
'nature_of_request': 'UUF Item - Modify existing docs',
|
||||
'mydoc_url': doc_url,
|
||||
'text_to_change': text_to_change,
|
||||
'new_text': new_text,
|
||||
'github_info': github_info,
|
||||
'status': 'Ready',
|
||||
'original_new_text': new_text,
|
||||
'source': 'UUF' # Mark as UUF item
|
||||
}
|
||||
|
||||
self.log(f"Successfully processed UUF item {uuf_id}")
|
||||
return processed_item
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error processing UUF item {uuf_item.get('cr4af_uufid', 'unknown')}: {str(e)}")
|
||||
return None
|
||||
|
||||
def _extract_github_info(self, doc_url: str) -> dict:
|
||||
"""Extract GitHub repository info and ms.author from document URL
|
||||
|
||||
If GITHUB_REPO is configured in .env, it will be used instead of the repo
|
||||
extracted from the document metadata. This allows you to create PRs in your
|
||||
fork while preserving the file path and ms.author from the original document.
|
||||
"""
|
||||
try:
|
||||
# Fetch the document
|
||||
headers = {'User-Agent': USER_AGENT}
|
||||
response = requests.get(doc_url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
|
||||
# Extract ms.author
|
||||
ms_author = self._extract_meta_tag(html, 'ms.author')
|
||||
|
||||
# Extract original_content_git_url
|
||||
original_content_git_url = self._extract_meta_tag(html, 'original_content_git_url')
|
||||
|
||||
if not original_content_git_url:
|
||||
# Try alternative extraction method
|
||||
match = re.search(r"original_content_git_url[\"\']?\s*[:=]\s*[\"\']([^\"']+)[\"']", html, re.IGNORECASE)
|
||||
if match:
|
||||
original_content_git_url = match.group(1).strip()
|
||||
|
||||
if not original_content_git_url:
|
||||
raise ValueError("original_content_git_url not found in document")
|
||||
|
||||
# Check if GITHUB_REPO is configured in .env
|
||||
# If it is, use that instead of the repo from the document
|
||||
configured_repo = self.config.get('GITHUB_REPO')
|
||||
|
||||
if configured_repo and '/' in configured_repo:
|
||||
# Use the configured repository (e.g., "b-tsammons/fabric-docs-pr")
|
||||
parts = configured_repo.split('/', 1)
|
||||
owner = parts[0].strip()
|
||||
repo = parts[1].strip()
|
||||
self.log(f"Using configured GITHUB_REPO: {owner}/{repo} (overriding document metadata)")
|
||||
else:
|
||||
# Parse GitHub owner/repo from original_content_git_url (fallback to document metadata)
|
||||
owner, repo = self._parse_github_url(original_content_git_url)
|
||||
self.log(f"Using repository from document metadata: {owner}/{repo}")
|
||||
|
||||
return {
|
||||
'ms_author': ms_author,
|
||||
'original_content_git_url': original_content_git_url,
|
||||
'owner': owner,
|
||||
'repo': repo
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error extracting GitHub info from {doc_url}: {str(e)}")
|
||||
return {
|
||||
'ms_author': None,
|
||||
'original_content_git_url': None,
|
||||
'owner': None,
|
||||
'repo': None,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _extract_meta_tag(self, html: str, name: str) -> str | None:
|
||||
"""Extract content from meta tag"""
|
||||
pattern = rf'<meta\s+(?:[^>]*?\s)?(?:name|property)\s*=\s*["\'](?P<n>{re.escape(name)})["\']\s+[^>]*?\bcontent\s*=\s*["\'](?P<content>[^"\']+)["\'][^>]*?>'
|
||||
match = re.search(pattern, html, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group('content').strip()
|
||||
return None
|
||||
|
||||
def _parse_github_url(self, url: str) -> tuple[str, str]:
|
||||
"""Parse GitHub URL to extract owner and repo"""
|
||||
parsed = urlparse(url)
|
||||
if "github.com" not in parsed.netloc.lower():
|
||||
raise ValueError(f"Not a GitHub URL: {url}")
|
||||
parts = [p for p in parsed.path.split("/") if p]
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Unable to parse owner/repo from: {url}")
|
||||
return parts[0], parts[1]
|
||||
@@ -12,7 +12,7 @@ from urllib.parse import urlparse
|
||||
|
||||
# Constants
|
||||
GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
|
||||
USER_AGENT = "azure-devops-github-processor/2.0"
|
||||
USER_AGENT = "github-automation-tool/1.0"
|
||||
|
||||
|
||||
class GitHubGQL:
|
||||
@@ -552,7 +552,7 @@ class GitHubGQL:
|
||||
raise
|
||||
|
||||
def create_pull_request(self, repository_id: str, title: str, body: str, head_ref: str, base_ref: str = "main") -> tuple[str, str, int]:
|
||||
"""Create a pull request with AB# linking"""
|
||||
"""Create a pull request"""
|
||||
self.log(f"Creating pull request with createPullRequest mutation from {head_ref} to {base_ref}...")
|
||||
mutation = """
|
||||
mutation($repositoryId:ID!, $title:String!, $body:String!, $headRefName:String!, $baseRefName:String!) {
|
||||
@@ -650,7 +650,7 @@ class GitHubGQL:
|
||||
|
||||
def add_copilot_comment(self, owner: str, repo: str, pr_number: int,
|
||||
file_path: str, old_text: str, new_text: str, branch_name: str,
|
||||
work_item_id: str = None, item_source: str = None, doc_url: str = None,
|
||||
work_item_id: str = None, item_source: str = None, doc_url: str = None,
|
||||
custom_instructions: str = None) -> bool:
|
||||
"""Add a comment mentioning @copilot with explicit instructions to work on THIS PR
|
||||
|
||||
@@ -664,8 +664,8 @@ class GitHubGQL:
|
||||
old_text: Text to find and replace
|
||||
new_text: New text to replace with
|
||||
branch_name: Branch name for this PR
|
||||
work_item_id: Work item or UUF issue ID
|
||||
item_source: Source of the item ('UUF' or 'Azure DevOps')
|
||||
work_item_id: Reference ID for tracking (optional)
|
||||
item_source: Source of the item (optional)
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
@@ -680,25 +680,22 @@ class GitHubGQL:
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
# Build work item reference
|
||||
# Build reference ID if provided
|
||||
if work_item_id:
|
||||
if item_source == 'UUF':
|
||||
work_item_ref = f"**UUF Issue:** {work_item_id}\n"
|
||||
else:
|
||||
work_item_ref = f"**Azure DevOps Work Item:** AB#{work_item_id}\n"
|
||||
reference_id = f"**Reference ID:** {work_item_id}\n"
|
||||
else:
|
||||
work_item_ref = ""
|
||||
reference_id = ""
|
||||
|
||||
# Build document reference
|
||||
if file_path and not file_path.startswith("See work item") and not file_path.startswith("File path not specified"):
|
||||
if file_path and not file_path.startswith("File path not specified"):
|
||||
doc_ref = f"**Document to modify:** `{file_path}`\n"
|
||||
file_instruction = f"2. Locate the file: `{file_path}`"
|
||||
elif doc_url:
|
||||
doc_ref = f"**Document URL:** {doc_url}\n"
|
||||
file_instruction = f"2. Locate the file from this document URL: {doc_url}"
|
||||
else:
|
||||
doc_ref = "**Note:** File path not specified in work item\n"
|
||||
file_instruction = "2. Review the PR description and work item details to identify the file(s) that need to be modified"
|
||||
doc_ref = "**Note:** File path not specified\n"
|
||||
file_instruction = "2. Review the PR description to identify the file(s) that need to be modified"
|
||||
|
||||
# Build custom instructions section
|
||||
if custom_instructions and custom_instructions.strip():
|
||||
@@ -713,25 +710,24 @@ class GitHubGQL:
|
||||
# Create a comment mentioning @copilot with VERY explicit instructions
|
||||
comment_body = f"""@copilot
|
||||
|
||||
{work_item_ref}{doc_ref}
|
||||
{reference_id}{doc_ref}
|
||||
|
||||
**Instructions:**
|
||||
|
||||
Task: Update the documentation file with the changes requested above.
|
||||
Task: Update the file with the changes requested above.
|
||||
|
||||
Steps to complete:
|
||||
|
||||
Locate the file containing the reference shown below.
|
||||
Find the reference text within the file
|
||||
Replace it with the 'Proposed New Text' shown above or use the reference as guidance
|
||||
Maintain the existing formatting, indentation, and markdown structure
|
||||
Maintain the existing formatting, indentation, and structure
|
||||
Ensure no other content in the file is modified
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Only replace the specified text - do not make additional changes.
|
||||
> Preserve all markdown formatting, links, and code blocks.
|
||||
> Preserve all formatting, links, and code blocks.
|
||||
> If the current text cannot be found exactly, search for similar text.
|
||||
> Please ensure the changes align with Microsoft documentation standards.
|
||||
> Do not remove any text unless the reference or suggested guidance indicates to do so, if the text is obsolete or incorrect.
|
||||
|
||||
1. Make changes to `{branch_name}` branch for this pull request.
|
||||
@@ -748,14 +744,13 @@ Ensure no other content in the file is modified
|
||||
{new_text}
|
||||
```
|
||||
|
||||
5. Ensure the changes align with the context of the work item.
|
||||
5. Ensure the changes align with the context provided.
|
||||
|
||||
6. Do a freshness check to ensure the file content is up-to-date before making changes.
|
||||
|
||||
7. Commit the changes to the `{branch_name}` branch
|
||||
|
||||
> [!NOTE]
|
||||
> This documentation is maintained by spelluru.
|
||||
> If guidance is empty, follow the reference to make changes.
|
||||
|
||||
{custom_instructions_section}
|
||||
@@ -848,7 +843,7 @@ Thank you!
|
||||
{new_text}
|
||||
```
|
||||
|
||||
**Automated Suggestion:** This change was requested in Azure DevOps work item.
|
||||
**Automated Suggestion:** This change was requested.
|
||||
|
||||
Click "Commit suggestion" above to apply this change directly to the PR."""
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ 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
|
||||
# Removed imports: WorkItemProcessor, AzureDevOpsAPI, DataverseAPI
|
||||
# These were specific to Azure DevOps integration
|
||||
|
||||
|
||||
class HyperlinkDialog:
|
||||
@@ -135,9 +134,10 @@ class MainGUI:
|
||||
|
||||
# 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 work item processor - REMOVED (was Azure DevOps specific)
|
||||
# self.work_item_processor = WorkItemProcessor(self.logger, self.config_manager.get_config())
|
||||
self.work_item_processor = None # Placeholder for future implementation
|
||||
|
||||
# Initialize cache manager
|
||||
from .cache_manager import CacheManager
|
||||
@@ -222,7 +222,7 @@ class MainGUI:
|
||||
title_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(title_frame, text="MicrosoftDocFlow v3",
|
||||
title_label = ttk.Label(title_frame, text="GitHub Pulse",
|
||||
font=('Arial', 16, 'bold'))
|
||||
title_label.grid(row=0, column=0, sticky=tk.W)
|
||||
|
||||
@@ -237,53 +237,177 @@ class MainGUI:
|
||||
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))
|
||||
"""Create GitHub Tools section"""
|
||||
# GitHub Tools group frame
|
||||
tools_frame = ttk.LabelFrame(parent, text="🔧 GitHub Tools",
|
||||
style='WorkItem.TLabelframe', padding="15")
|
||||
tools_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 15), padx=5)
|
||||
tools_frame.columnconfigure(1, weight=1)
|
||||
|
||||
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))
|
||||
# Initialize workflow data
|
||||
self.target_repos = []
|
||||
self.forked_repos = []
|
||||
self.workflow_items = []
|
||||
self.current_workflow_items = []
|
||||
|
||||
# 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))
|
||||
# Get current config
|
||||
config = self.config_manager.get_config()
|
||||
|
||||
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))
|
||||
# Row 0: Mode Selection
|
||||
mode_frame = ttk.Frame(tools_frame)
|
||||
mode_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10), padx=5)
|
||||
|
||||
# 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("<<ComboboxSelected>>", lambda e: self.update_action_button_text())
|
||||
ttk.Label(mode_frame, text="Mode:", font=('Arial', 10, 'bold')).grid(
|
||||
row=0, column=0, sticky=tk.W, padx=(0, 10))
|
||||
|
||||
# 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))
|
||||
self.tools_mode_var = tk.StringVar(value="action")
|
||||
create_radio = ttk.Radiobutton(mode_frame, text="✏️ Create PR/Issue", variable=self.tools_mode_var,
|
||||
value="create", command=self._on_mode_changed)
|
||||
create_radio.grid(row=0, column=1, padx=(0, 15))
|
||||
|
||||
action_radio = ttk.Radiobutton(mode_frame, text="📋 Action Existing PR/Issue", variable=self.tools_mode_var,
|
||||
value="action", command=self._on_mode_changed)
|
||||
action_radio.grid(row=0, column=2, padx=(0, 10))
|
||||
|
||||
# Separator
|
||||
ttk.Separator(tools_frame, orient='horizontal').grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(5, 10))
|
||||
|
||||
# Row 2: Target Repository
|
||||
self.target_repo_label = ttk.Label(tools_frame, text="Target Repository:", font=('Arial', 10, 'bold'))
|
||||
self.target_repo_label.grid(row=2, column=0, sticky=tk.W, pady=5, padx=5)
|
||||
|
||||
target_frame = ttk.Frame(tools_frame)
|
||||
target_frame.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5, padx=5)
|
||||
target_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.target_repo_var = tk.StringVar(value=config.get('GITHUB_REPO', ''))
|
||||
self.target_repo_dropdown = ttk.Combobox(target_frame, textvariable=self.target_repo_var,
|
||||
values=[''], width=60)
|
||||
self.target_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
self.target_repo_dropdown.bind('<KeyRelease>', self._on_target_repo_search)
|
||||
self.target_repo_dropdown.bind('<<ComboboxSelected>>', lambda e: self._on_repo_selection_changed())
|
||||
|
||||
refresh_target_btn = ttk.Button(target_frame, text="🔄", width=3,
|
||||
command=self._refresh_target_repos)
|
||||
refresh_target_btn.grid(row=0, column=1, padx=(0, 2))
|
||||
|
||||
search_target_btn = ttk.Button(target_frame, text="🔍", width=3,
|
||||
command=self._search_target_repos)
|
||||
search_target_btn.grid(row=0, column=2)
|
||||
|
||||
# Row 3: Forked Repository
|
||||
self.forked_repo_label = ttk.Label(tools_frame, text="Forked Repository:", font=('Arial', 10, 'bold'))
|
||||
self.forked_repo_label.grid(row=3, column=0, sticky=tk.W, pady=5, padx=5)
|
||||
|
||||
self.fork_frame = ttk.Frame(tools_frame)
|
||||
self.fork_frame.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5, padx=5)
|
||||
self.fork_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.forked_repo_var = tk.StringVar(value=config.get('FORKED_REPO', ''))
|
||||
self.forked_repo_dropdown = ttk.Combobox(self.fork_frame, textvariable=self.forked_repo_var,
|
||||
values=[''], width=60)
|
||||
self.forked_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
self.forked_repo_dropdown.bind('<<ComboboxSelected>>', lambda e: self._on_repo_selection_changed())
|
||||
|
||||
refresh_fork_btn = ttk.Button(self.fork_frame, text="🔄", width=3,
|
||||
command=self._refresh_forked_repos)
|
||||
refresh_fork_btn.grid(row=0, column=1, padx=(0, 2))
|
||||
|
||||
clone_fork_btn = ttk.Button(self.fork_frame, text="📥", width=3,
|
||||
command=self._clone_forked_repo)
|
||||
clone_fork_btn.grid(row=0, column=2)
|
||||
|
||||
# Row 4: Action Mode Controls (View toggles and load button)
|
||||
self.action_controls_row = ttk.Frame(tools_frame)
|
||||
self.action_controls_row.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 5), padx=5)
|
||||
self.action_controls_row.columnconfigure(2, weight=1)
|
||||
|
||||
# Repo source toggle
|
||||
ttk.Label(self.action_controls_row, text="View:", font=('Arial', 10, 'bold')).grid(
|
||||
row=0, column=0, sticky=tk.W, padx=(0, 5))
|
||||
|
||||
self.repo_source_var = tk.StringVar(value="target")
|
||||
target_radio = ttk.Radiobutton(self.action_controls_row, text="Target", variable=self.repo_source_var,
|
||||
value="target", command=self._filter_workflow_items)
|
||||
target_radio.grid(row=0, column=1, padx=(0, 5))
|
||||
|
||||
fork_radio = ttk.Radiobutton(self.action_controls_row, text="Fork", variable=self.repo_source_var,
|
||||
value="fork", command=self._filter_workflow_items)
|
||||
fork_radio.grid(row=0, column=2, padx=(0, 15))
|
||||
|
||||
# Item type toggle
|
||||
self.item_type_var = tk.StringVar(value="pull_request")
|
||||
pr_radio = ttk.Radiobutton(self.action_controls_row, text="PRs", variable=self.item_type_var,
|
||||
value="pull_request", command=self._filter_workflow_items)
|
||||
pr_radio.grid(row=0, column=3, padx=(0, 5))
|
||||
|
||||
issue_radio = ttk.Radiobutton(self.action_controls_row, text="Issues", variable=self.item_type_var,
|
||||
value="issue", command=self._filter_workflow_items)
|
||||
issue_radio.grid(row=0, column=4, padx=(0, 15))
|
||||
|
||||
# Fetch button
|
||||
self.fetch_workflow_btn = ttk.Button(self.action_controls_row, text="📥 Load Items",
|
||||
command=self._load_workflow_items)
|
||||
self.fetch_workflow_btn.grid(row=0, column=5, padx=(0, 10))
|
||||
|
||||
# Item counter
|
||||
self.item_counter_label = ttk.Label(controls_row, text="No items loaded",
|
||||
self.item_counter_label = ttk.Label(self.action_controls_row, text="No items loaded",
|
||||
font=('Arial', 9, 'italic'))
|
||||
self.item_counter_label.grid(row=0, column=6, sticky=tk.E)
|
||||
|
||||
# Row 5: Workflow items dropdown (Action Mode)
|
||||
self.action_item_label = ttk.Label(tools_frame, text="Select Item:", font=('Arial', 10, 'bold'))
|
||||
self.action_item_label.grid(row=5, column=0, sticky=tk.W, pady=5, padx=5)
|
||||
|
||||
action_item_dropdown_frame = ttk.Frame(tools_frame)
|
||||
action_item_dropdown_frame.grid(row=5, column=1, sticky=(tk.W, tk.E), pady=5, padx=5)
|
||||
action_item_dropdown_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.workflow_item_var = tk.StringVar()
|
||||
self.workflow_item_dropdown = ttk.Combobox(action_item_dropdown_frame, textvariable=self.workflow_item_var,
|
||||
values=[''], width=60, state='readonly')
|
||||
self.workflow_item_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E))
|
||||
self.workflow_item_dropdown.bind('<<ComboboxSelected>>', self._on_workflow_item_selected)
|
||||
|
||||
# Row 4-5: Create Mode Controls (hidden by default)
|
||||
self.create_controls_row = ttk.Frame(tools_frame)
|
||||
self.create_controls_row.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 5), padx=5)
|
||||
self.create_controls_row.columnconfigure(1, weight=1)
|
||||
|
||||
# Create type selection
|
||||
ttk.Label(self.create_controls_row, text="Create:", font=('Arial', 10, 'bold')).grid(
|
||||
row=0, column=0, sticky=tk.W, padx=(0, 5))
|
||||
|
||||
self.create_type_var = tk.StringVar(value="pull_request")
|
||||
create_pr_radio = ttk.Radiobutton(self.create_controls_row, text="📝 Pull Request",
|
||||
variable=self.create_type_var, value="pull_request")
|
||||
create_pr_radio.grid(row=0, column=1, padx=(0, 15), sticky=tk.W)
|
||||
|
||||
create_issue_radio = ttk.Radiobutton(self.create_controls_row, text="🐛 Issue",
|
||||
variable=self.create_type_var, value="issue")
|
||||
create_issue_radio.grid(row=0, column=2, padx=(0, 15), sticky=tk.W)
|
||||
|
||||
# Create button
|
||||
self.create_item_btn = ttk.Button(self.create_controls_row, text="✏️ Create New",
|
||||
command=self._create_new_item)
|
||||
self.create_item_btn.grid(row=0, column=3, padx=(0, 10))
|
||||
|
||||
# Store references for show/hide
|
||||
self.action_mode_widgets = [
|
||||
self.action_controls_row,
|
||||
self.action_item_label,
|
||||
action_item_dropdown_frame
|
||||
]
|
||||
|
||||
self.create_mode_widgets = [
|
||||
self.create_controls_row
|
||||
]
|
||||
|
||||
# Initialize mode (show action, hide create)
|
||||
self._on_mode_changed()
|
||||
|
||||
# Start loading repos
|
||||
self.root.after(100, self._init_load_repos)
|
||||
|
||||
def _create_status_section(self, parent):
|
||||
"""Create progress and status section"""
|
||||
@@ -664,7 +788,10 @@ class MainGUI:
|
||||
# Process items
|
||||
self.current_work_items = []
|
||||
for item in work_items:
|
||||
processed_item = self.work_item_processor.process_work_item(item)
|
||||
# REMOVED: Azure DevOps specific processing
|
||||
# processed_item = self.work_item_processor.process_work_item(item)
|
||||
# TODO: Implement custom item processing here
|
||||
processed_item = item # Placeholder
|
||||
if processed_item:
|
||||
self.current_work_items.append(processed_item)
|
||||
|
||||
@@ -723,7 +850,10 @@ class MainGUI:
|
||||
# Process items
|
||||
self.current_work_items = []
|
||||
for item in uuf_items:
|
||||
processed_item = self.work_item_processor.process_uuf_item(item)
|
||||
# REMOVED: UUF/Dataverse specific processing
|
||||
# processed_item = self.work_item_processor.process_uuf_item(item)
|
||||
# TODO: Implement custom item processing here
|
||||
processed_item = item # Placeholder
|
||||
if processed_item:
|
||||
self.current_work_items.append(processed_item)
|
||||
|
||||
@@ -1850,14 +1980,28 @@ Proposed new text:
|
||||
api_key = config.get('OPENAI_API_KEY', '').strip()
|
||||
elif ai_provider in ['github-copilot', 'copilot', 'github_copilot']:
|
||||
api_key = config.get('GITHUB_TOKEN', '').strip()
|
||||
elif ai_provider == 'ollama':
|
||||
api_key = config.get('OLLAMA_API_KEY', '').strip() # Optional for Ollama
|
||||
else:
|
||||
api_key = ''
|
||||
github_token = config.get('GITHUB_PAT', '').strip()
|
||||
local_repo_path = config.get('LOCAL_REPO_PATH', '').strip() or None
|
||||
|
||||
if not api_key:
|
||||
# Validate API key (except for Ollama where it's optional)
|
||||
if not api_key and ai_provider != 'ollama':
|
||||
raise ValueError(f"No API key configured for {ai_provider}. Please configure in Settings.")
|
||||
|
||||
# Get Ollama-specific configuration
|
||||
ollama_url = None
|
||||
ollama_model = None
|
||||
if ai_provider == 'ollama':
|
||||
ollama_url = config.get('OLLAMA_URL', '').strip()
|
||||
ollama_model = config.get('OLLAMA_MODEL', '').strip()
|
||||
if not ollama_url:
|
||||
raise ValueError("Ollama Server URL not configured. Please configure in Settings.")
|
||||
if not ollama_model:
|
||||
raise ValueError("Ollama Model not selected. Please configure in Settings.")
|
||||
|
||||
self.logger.log(f"Using AI Provider: {ai_provider.upper()}")
|
||||
|
||||
# Create AI manager
|
||||
@@ -1865,7 +2009,7 @@ Proposed new text:
|
||||
ai_manager = AIManager(self.logger)
|
||||
|
||||
# Create AI provider instance
|
||||
ai_provider_instance = ai_manager.create_ai_provider(ai_provider, api_key)
|
||||
ai_provider_instance = ai_manager.create_ai_provider(ai_provider, api_key, ollama_url, ollama_model)
|
||||
if not ai_provider_instance:
|
||||
raise ValueError(f"Failed to create {ai_provider} provider")
|
||||
|
||||
@@ -2161,19 +2305,465 @@ Proposed new text:
|
||||
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)}")
|
||||
|
||||
# ===== GitHub Tools Methods =====
|
||||
|
||||
def _init_load_repos(self):
|
||||
"""Initialize loading of repositories"""
|
||||
self._load_target_repos_async()
|
||||
self._load_forked_repos_async()
|
||||
|
||||
def _load_target_repos_async(self):
|
||||
"""Load target repositories asynchronously"""
|
||||
def load_repos():
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
github_token = config.get('GITHUB_PAT', '')
|
||||
if not github_token:
|
||||
return
|
||||
|
||||
from .workflow import GitHubRepoFetcher
|
||||
repo_fetcher = GitHubRepoFetcher(github_token, self.logger)
|
||||
repos = repo_fetcher.fetch_repos_with_permissions(min_permission='push')
|
||||
self.target_repos = repo_fetcher.get_repo_names(repos)
|
||||
|
||||
# Update dropdown on main thread
|
||||
self.root.after(0, self._update_target_dropdown)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error loading target repos: {e}")
|
||||
|
||||
threading.Thread(target=load_repos, daemon=True).start()
|
||||
|
||||
def _update_target_dropdown(self):
|
||||
"""Update the target repository dropdown"""
|
||||
try:
|
||||
current_values = [''] # Start with empty option
|
||||
|
||||
# Add user's repos with edit access
|
||||
if self.target_repos:
|
||||
current_values.append('--- Your Repos (with edit access) ---')
|
||||
current_values.extend(self.target_repos)
|
||||
|
||||
self.target_repo_dropdown['values'] = current_values
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error updating target dropdown: {e}")
|
||||
|
||||
def _refresh_target_repos(self):
|
||||
"""Refresh target repositories"""
|
||||
self._load_target_repos_async()
|
||||
|
||||
def _search_target_repos(self):
|
||||
"""Search for repositories on GitHub"""
|
||||
query = self.target_repo_var.get().strip()
|
||||
if not query:
|
||||
return
|
||||
|
||||
def search_repos():
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
github_token = config.get('GITHUB_PAT', '')
|
||||
if not github_token:
|
||||
return
|
||||
|
||||
from .workflow import GitHubRepoFetcher
|
||||
repo_fetcher = GitHubRepoFetcher(github_token, self.logger)
|
||||
repos = repo_fetcher.search_repositories(query, per_page=50)
|
||||
search_results = repo_fetcher.get_repo_names(repos)
|
||||
|
||||
# Update dropdown on main thread
|
||||
self.root.after(0, lambda: self._update_target_search_results(search_results))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error searching repos: {e}")
|
||||
|
||||
threading.Thread(target=search_repos, daemon=True).start()
|
||||
|
||||
def _update_target_search_results(self, search_results):
|
||||
"""Update target dropdown with search results"""
|
||||
try:
|
||||
current_values = ['']
|
||||
|
||||
# Add user's repos
|
||||
if self.target_repos:
|
||||
current_values.append('--- Your Repos (with edit access) ---')
|
||||
current_values.extend(self.target_repos)
|
||||
|
||||
# Add search results
|
||||
if search_results:
|
||||
current_values.append('--- Search Results ---')
|
||||
current_values.extend(search_results)
|
||||
|
||||
self.target_repo_dropdown['values'] = current_values
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error updating search results: {e}")
|
||||
|
||||
def _on_target_repo_search(self, _event):
|
||||
"""Handle typing in target repo field for auto-search"""
|
||||
# Debounce: only search after user stops typing for 500ms
|
||||
if hasattr(self, '_search_timer'):
|
||||
self.root.after_cancel(self._search_timer)
|
||||
|
||||
query = self.target_repo_var.get().strip()
|
||||
if len(query) >= 3: # Only search if at least 3 characters
|
||||
self._search_timer = self.root.after(500, self._search_target_repos)
|
||||
|
||||
def _load_forked_repos_async(self):
|
||||
"""Load forked repositories asynchronously"""
|
||||
def load_forks():
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
github_token = config.get('GITHUB_PAT', '')
|
||||
local_repo_path = config.get('LOCAL_REPO_PATH', '')
|
||||
|
||||
# Load local repos
|
||||
local_repos = []
|
||||
if local_repo_path:
|
||||
try:
|
||||
from .utils import LocalRepositoryScanner
|
||||
local_repos = LocalRepositoryScanner.scan_local_repos(local_repo_path)
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error scanning local repos: {e}")
|
||||
|
||||
# Load GitHub repos
|
||||
github_repos = []
|
||||
if github_token:
|
||||
try:
|
||||
from .workflow import GitHubRepoFetcher
|
||||
repo_fetcher = GitHubRepoFetcher(github_token, self.logger)
|
||||
repos = repo_fetcher.fetch_user_repos(repo_type='owner')
|
||||
github_repos = repo_fetcher.get_repo_names(repos)
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error loading GitHub repos: {e}")
|
||||
|
||||
self.forked_repos = {'local': local_repos, 'github': github_repos}
|
||||
|
||||
# Update dropdown on main thread
|
||||
self.root.after(0, self._update_forked_dropdown)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error loading forked repos: {e}")
|
||||
|
||||
threading.Thread(target=load_forks, daemon=True).start()
|
||||
|
||||
def _update_forked_dropdown(self):
|
||||
"""Update the forked repository dropdown"""
|
||||
try:
|
||||
current_values = [''] # Start with empty option
|
||||
|
||||
# Add local repos
|
||||
if self.forked_repos.get('local'):
|
||||
current_values.append('--- Local Repositories ---')
|
||||
current_values.extend(self.forked_repos['local'])
|
||||
|
||||
# Add GitHub repos
|
||||
if self.forked_repos.get('github'):
|
||||
current_values.append('--- Your GitHub Forks ---')
|
||||
current_values.extend(self.forked_repos['github'])
|
||||
|
||||
self.forked_repo_dropdown['values'] = current_values
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error updating forked dropdown: {e}")
|
||||
|
||||
def _refresh_forked_repos(self):
|
||||
"""Refresh forked repositories"""
|
||||
self._load_forked_repos_async()
|
||||
|
||||
def _clone_forked_repo(self):
|
||||
"""Clone the selected forked repository"""
|
||||
selected_repo = self.forked_repo_var.get().strip()
|
||||
|
||||
# Validate selection
|
||||
if not selected_repo:
|
||||
messagebox.showwarning("No Repository Selected",
|
||||
"Please select a repository to clone.")
|
||||
return
|
||||
|
||||
# Check if it's a section header
|
||||
if selected_repo.startswith('---'):
|
||||
messagebox.showwarning("Invalid Selection",
|
||||
"Please select a repository, not a section header.")
|
||||
return
|
||||
|
||||
config = self.config_manager.get_config()
|
||||
local_repo_path = config.get('LOCAL_REPO_PATH', '').strip()
|
||||
if not local_repo_path:
|
||||
messagebox.showwarning("Local Path Not Configured",
|
||||
"Please configure the Local Repository Path in settings first.")
|
||||
return
|
||||
|
||||
# Clone logic (similar to settings_dialog.py)
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
try:
|
||||
os.makedirs(local_repo_path, exist_ok=True)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Directory Error",
|
||||
f"Could not create local repository directory:\n{str(e)}")
|
||||
return
|
||||
|
||||
# Extract repo name
|
||||
repo_name = selected_repo
|
||||
if '/' not in repo_name:
|
||||
messagebox.showerror("Invalid Repository",
|
||||
"Repository must be in 'owner/repo' format.")
|
||||
return
|
||||
|
||||
folder_name = repo_name.split('/')[-1]
|
||||
target_path = os.path.join(local_repo_path, folder_name)
|
||||
|
||||
if os.path.exists(target_path):
|
||||
response = messagebox.askyesno("Directory Exists",
|
||||
f"The directory '{folder_name}' already exists.\n\n"
|
||||
f"Do you want to continue anyway?")
|
||||
if not response:
|
||||
return
|
||||
|
||||
clone_url = f"https://github.com/{repo_name}.git"
|
||||
|
||||
def clone_repo():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'clone', clone_url, target_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.root.after(0, lambda: messagebox.showinfo(
|
||||
"Clone Successful",
|
||||
f"Successfully cloned {repo_name}!"))
|
||||
self.root.after(0, self._refresh_forked_repos)
|
||||
else:
|
||||
error_msg = result.stderr if result.stderr else result.stdout
|
||||
self.root.after(0, lambda: messagebox.showerror(
|
||||
"Clone Failed",
|
||||
f"Failed to clone {repo_name}.\n\n{error_msg}"))
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.root.after(0, lambda: messagebox.showerror(
|
||||
"Clone Timeout",
|
||||
f"Cloning {repo_name} timed out after 5 minutes."))
|
||||
except FileNotFoundError:
|
||||
self.root.after(0, lambda: messagebox.showerror(
|
||||
"Git Not Found",
|
||||
"Git is not installed or not found in PATH."))
|
||||
except Exception as e:
|
||||
self.root.after(0, lambda: messagebox.showerror(
|
||||
"Clone Error",
|
||||
f"An error occurred while cloning:\n{str(e)}"))
|
||||
|
||||
messagebox.showinfo("Cloning Repository",
|
||||
f"Cloning {repo_name} to:\n{target_path}\n\n"
|
||||
f"This may take a few moments...")
|
||||
|
||||
threading.Thread(target=clone_repo, daemon=True).start()
|
||||
|
||||
def _on_mode_changed(self):
|
||||
"""Handle mode change between Create and Action"""
|
||||
mode = self.tools_mode_var.get()
|
||||
|
||||
if mode == "action":
|
||||
# Show action mode widgets
|
||||
for widget in self.action_mode_widgets:
|
||||
widget.grid()
|
||||
|
||||
# Hide create mode widgets
|
||||
for widget in self.create_mode_widgets:
|
||||
widget.grid_remove()
|
||||
else: # create mode
|
||||
# Hide action mode widgets
|
||||
for widget in self.action_mode_widgets:
|
||||
widget.grid_remove()
|
||||
|
||||
# Show create mode widgets
|
||||
for widget in self.create_mode_widgets:
|
||||
widget.grid()
|
||||
|
||||
def _create_new_item(self):
|
||||
"""Handle creating a new PR or Issue"""
|
||||
create_type = self.create_type_var.get()
|
||||
target_repo = self.target_repo_var.get().strip()
|
||||
|
||||
# Skip section headers
|
||||
if target_repo.startswith('---'):
|
||||
target_repo = ''
|
||||
|
||||
if not target_repo:
|
||||
messagebox.showwarning("No Repository Selected",
|
||||
"Please select a target repository.")
|
||||
return
|
||||
|
||||
if create_type == "pull_request":
|
||||
# TODO: Implement PR creation workflow
|
||||
messagebox.showinfo("Create Pull Request",
|
||||
f"PR creation workflow for {target_repo} will be implemented here.\n\n"
|
||||
"This will open the PR creation interface in the tabs below.")
|
||||
else: # issue
|
||||
# TODO: Implement Issue creation workflow
|
||||
messagebox.showinfo("Create Issue",
|
||||
f"Issue creation workflow for {target_repo} will be implemented here.\n\n"
|
||||
"This will open the Issue creation interface in the tabs below.")
|
||||
|
||||
def _on_repo_selection_changed(self):
|
||||
"""Handle repository selection change"""
|
||||
# Clear workflow items when repos change
|
||||
self.workflow_items = []
|
||||
self.current_workflow_items = []
|
||||
self.workflow_item_dropdown['values'] = ['']
|
||||
self.workflow_item_var.set('')
|
||||
self.item_counter_label.config(text="No items loaded")
|
||||
|
||||
def _load_workflow_items(self):
|
||||
"""Load workflow items from selected repositories"""
|
||||
target_repo = self.target_repo_var.get().strip()
|
||||
forked_repo = self.forked_repo_var.get().strip()
|
||||
|
||||
# Skip section headers
|
||||
if target_repo.startswith('---'):
|
||||
target_repo = ''
|
||||
if forked_repo.startswith('---'):
|
||||
forked_repo = ''
|
||||
|
||||
if not target_repo and not forked_repo:
|
||||
messagebox.showwarning("No Repositories Selected",
|
||||
"Please select at least one repository.")
|
||||
return
|
||||
|
||||
self.progress.start()
|
||||
self.update_status("Loading workflow items...")
|
||||
|
||||
def load_items():
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
github_token = config.get('GITHUB_PAT', '')
|
||||
|
||||
from .workflow import WorkflowManager
|
||||
workflow_manager = WorkflowManager(github_token, self.logger)
|
||||
|
||||
# Fetch all items
|
||||
results = workflow_manager.fetch_all_workflow_items(
|
||||
target_repo=target_repo if target_repo else None,
|
||||
fork_repo=forked_repo if forked_repo else None,
|
||||
include_issues=True,
|
||||
include_prs=True,
|
||||
state='open' # Only load open items
|
||||
)
|
||||
|
||||
self.workflow_items = results
|
||||
|
||||
# Update UI on main thread
|
||||
self.root.after(0, self._on_workflow_items_loaded)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error loading workflow items: {e}")
|
||||
self.root.after(0, lambda: self.update_status("Failed to load workflow items"))
|
||||
self.root.after(0, lambda: messagebox.showerror(
|
||||
"Load Error",
|
||||
f"Failed to load workflow items:\n{str(e)}"))
|
||||
finally:
|
||||
self.root.after(0, self.progress.stop)
|
||||
|
||||
threading.Thread(target=load_items, daemon=True).start()
|
||||
|
||||
def _on_workflow_items_loaded(self):
|
||||
"""Handle workflow items loaded"""
|
||||
total_items = sum(len(items) for items in self.workflow_items.values())
|
||||
self.logger.log(f"Loaded {total_items} workflow items")
|
||||
self.update_status(f"Loaded {total_items} workflow items")
|
||||
|
||||
# Apply current filters
|
||||
self._filter_workflow_items()
|
||||
|
||||
def _filter_workflow_items(self):
|
||||
"""Filter workflow items based on current selections"""
|
||||
if not self.workflow_items:
|
||||
return
|
||||
|
||||
repo_source = self.repo_source_var.get() # 'target' or 'fork'
|
||||
item_type = self.item_type_var.get() # 'pull_request' or 'issue'
|
||||
|
||||
# Get the appropriate list
|
||||
# WorkflowManager returns keys like: 'target_prs', 'target_issues', 'fork_prs', 'fork_issues'
|
||||
key = f"{repo_source}_prs" if item_type == 'pull_request' else f"{repo_source}_issues"
|
||||
filtered_items = self.workflow_items.get(key, [])
|
||||
|
||||
# Update dropdown
|
||||
self.current_workflow_items = filtered_items
|
||||
item_options = [
|
||||
f"#{item.number} - {item.title}" for item in filtered_items
|
||||
]
|
||||
|
||||
self.workflow_item_dropdown['values'] = item_options if item_options else ['']
|
||||
self.workflow_item_var.set('')
|
||||
|
||||
# Update counter
|
||||
count = len(filtered_items)
|
||||
source_name = "Target" if repo_source == "target" else "Fork"
|
||||
type_name = "PRs" if item_type == "pull_request" else "Issues"
|
||||
self.item_counter_label.config(text=f"{count} {source_name} {type_name}")
|
||||
|
||||
def _on_workflow_item_selected(self, _event):
|
||||
"""Handle workflow item selection"""
|
||||
selected = self.workflow_item_var.get()
|
||||
if not selected:
|
||||
return
|
||||
|
||||
# Extract item number from selection
|
||||
try:
|
||||
item_number = int(selected.split('#')[1].split(' ')[0])
|
||||
|
||||
# Find the item
|
||||
for item in self.current_workflow_items:
|
||||
if item.number == item_number:
|
||||
self._display_workflow_item(item)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"Error selecting workflow item: {e}")
|
||||
|
||||
def _display_workflow_item(self, item):
|
||||
"""Display workflow item details"""
|
||||
# Update Current Work Item tab
|
||||
self.work_item_id_label.config(text=f"{item.item_type.upper()} #{item.number}")
|
||||
|
||||
# Update nature text
|
||||
self.nature_text.config(state='normal')
|
||||
self.nature_text.delete('1.0', tk.END)
|
||||
self.nature_text.insert('1.0', item.title)
|
||||
self.nature_text.config(state='disabled')
|
||||
|
||||
# Update URL
|
||||
self.doc_url_text.config(state='normal')
|
||||
self.doc_url_text.delete('1.0', tk.END)
|
||||
self.doc_url_text.insert('1.0', item.url)
|
||||
self.doc_url_text.config(state='disabled')
|
||||
|
||||
# Update description
|
||||
self.description_text.config(state='normal')
|
||||
self.description_text.delete('1.0', tk.END)
|
||||
self.description_text.insert('1.0', item.body or 'No description')
|
||||
self.description_text.config(state='disabled')
|
||||
|
||||
self.logger.log(f"Displaying {item.item_type} #{item.number}: {item.title}")
|
||||
|
||||
def display_current_item(self):
|
||||
"""Display current work item (public method for compatibility)"""
|
||||
|
||||
@@ -5,6 +5,7 @@ GUI for configuring application settings
|
||||
|
||||
import tkinter as tk
|
||||
import threading
|
||||
import subprocess
|
||||
from tkinter import ttk, messagebox, scrolledtext
|
||||
from typing import Dict, Any, Optional
|
||||
import sys
|
||||
@@ -46,7 +47,7 @@ class SettingsDialog:
|
||||
# Create tabs
|
||||
self._create_general_tab(notebook)
|
||||
self._create_ai_tab(notebook)
|
||||
self._create_dataverse_tab(notebook)
|
||||
# Removed: self._create_dataverse_tab(notebook) - Azure DevOps/Dataverse specific
|
||||
|
||||
# Buttons frame
|
||||
buttons_frame = ttk.Frame(main_frame)
|
||||
@@ -83,17 +84,10 @@ class SettingsDialog:
|
||||
scrollable_frame.columnconfigure(1, weight=1)
|
||||
|
||||
current_row = 0
|
||||
|
||||
# Azure DevOps section
|
||||
self._create_section_header(scrollable_frame, current_row, "🔷 Azure DevOps Configuration")
|
||||
current_row += 1
|
||||
|
||||
self._create_label_entry(scrollable_frame, current_row, "Query URL:", 'AZURE_DEVOPS_QUERY', width=60, multiline=True)
|
||||
current_row += 1
|
||||
|
||||
self._create_label_entry(scrollable_frame, current_row, "Personal Access Token:", 'AZURE_DEVOPS_PAT', password=True, width=60)
|
||||
current_row += 1
|
||||
|
||||
|
||||
# REMOVED: Azure DevOps Configuration section
|
||||
# This was specific to Azure DevOps integration
|
||||
|
||||
# GitHub section
|
||||
self._create_section_header(scrollable_frame, current_row, "🐙 GitHub Configuration")
|
||||
current_row += 1
|
||||
@@ -101,9 +95,9 @@ class SettingsDialog:
|
||||
self._create_label_entry(scrollable_frame, current_row, "Personal Access Token:", 'GITHUB_PAT', password=True, width=60)
|
||||
current_row += 1
|
||||
|
||||
self._create_label_entry(scrollable_frame, current_row, "Target Repository (owner/repo):", 'GITHUB_REPO', width=60)
|
||||
self._create_target_repo_dropdown(scrollable_frame, current_row)
|
||||
current_row += 1
|
||||
|
||||
|
||||
self._create_forked_repo_dropdown(scrollable_frame, current_row)
|
||||
current_row += 1
|
||||
|
||||
@@ -148,14 +142,13 @@ class SettingsDialog:
|
||||
|
||||
# Help text
|
||||
help_text = ttk.Label(scrollable_frame, text="💡 Getting Started:\n"
|
||||
"1. Set your Azure DevOps Query URL (copy from browser)\n"
|
||||
"2. Create Personal Access Tokens for both services\n"
|
||||
"3. Configure GitHub repositories:\n"
|
||||
"1. Create a GitHub Personal Access Token\n"
|
||||
"2. Configure GitHub repositories:\n"
|
||||
" • Target Repository: Where PRs will be created\n"
|
||||
" • Forked Repository: Your fork where changes are made\n"
|
||||
"4. Set Local Repo Path for automatic repository detection\n"
|
||||
"5. Configure AI provider in the AI tab (optional)\n"
|
||||
"6. Test your connection before processing items",
|
||||
"3. Set Local Repo Path for automatic repository detection\n"
|
||||
"4. Configure AI provider in the AI tab (optional)\n"
|
||||
"5. Test your connection before processing items",
|
||||
font=('Arial', 9), foreground='blue', justify=tk.LEFT, wraplength=850)
|
||||
help_text.grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(20, 30), padx=10)
|
||||
|
||||
@@ -193,7 +186,7 @@ class SettingsDialog:
|
||||
|
||||
self.ai_provider_var = tk.StringVar(value=self.config.get('AI_PROVIDER', 'none'))
|
||||
provider_dropdown = ttk.Combobox(scrollable_frame, textvariable=self.ai_provider_var,
|
||||
values=['none', 'claude', 'chatgpt', 'github-copilot'], state='readonly', width=47)
|
||||
values=['none', 'claude', 'chatgpt', 'github-copilot', 'ollama'], state='readonly', width=47)
|
||||
provider_dropdown.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
self.entries['AI_PROVIDER'] = self.ai_provider_var
|
||||
|
||||
@@ -202,6 +195,13 @@ class SettingsDialog:
|
||||
self._create_label_entry(scrollable_frame, 3, "ChatGPT API Key:", 'OPENAI_API_KEY', password=True)
|
||||
self._create_label_entry(scrollable_frame, 4, "GitHub Token (for Copilot) [defaults to GitHub PAT]:", 'GITHUB_TOKEN', password=True)
|
||||
|
||||
# Ollama Configuration
|
||||
self._create_label_entry(scrollable_frame, 5, "Ollama Server URL:", 'OLLAMA_URL')
|
||||
self._create_label_entry(scrollable_frame, 6, "Ollama API Key (optional):", 'OLLAMA_API_KEY', password=True)
|
||||
|
||||
# Ollama Model Dropdown
|
||||
self._create_ollama_model_dropdown(scrollable_frame, 7)
|
||||
|
||||
# Help text
|
||||
help_text = ttk.Label(scrollable_frame, text="\n💡 Tips:\n"
|
||||
"• Provider: Choose 'none' to disable AI (uses Copilot workflow)\n"
|
||||
@@ -209,58 +209,21 @@ class SettingsDialog:
|
||||
"• ChatGPT: Get key at platform.openai.com/api-keys\n"
|
||||
"• GitHub Copilot: Uses GitHub Models API (requires GitHub token)\n"
|
||||
"• GitHub Token: Auto-defaults to GitHub PAT if left empty\n"
|
||||
"• Cost: ~$0.01-0.05 per PR with AI, free with 'none'\n"
|
||||
"• Ollama: Self-hosted AI (requires Ollama server running)\n"
|
||||
"• Cost: ~$0.01-0.05 per PR with AI, free with 'none' and Ollama\n"
|
||||
"• AI providers clone repos locally to make changes before pushing",
|
||||
font=('Arial', 9), foreground='blue', justify=tk.LEFT, wraplength=800)
|
||||
help_text.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=20, padx=10)
|
||||
help_text.grid(row=8, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=20, padx=10)
|
||||
|
||||
# Pack canvas and scrollbar
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
def _create_dataverse_tab(self, notebook):
|
||||
"""Create Dataverse/PowerApp settings tab"""
|
||||
dataverse_frame = ttk.Frame(notebook)
|
||||
notebook.add(dataverse_frame, text="UUF/Dataverse")
|
||||
|
||||
# Scrollable frame
|
||||
canvas = tk.Canvas(dataverse_frame)
|
||||
scrollbar = ttk.Scrollbar(dataverse_frame, orient="vertical", command=canvas.yview)
|
||||
scrollable_frame = ttk.Frame(canvas)
|
||||
|
||||
scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
)
|
||||
|
||||
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Dataverse section
|
||||
self._create_section_header(scrollable_frame, 0, "📊 PowerApp/Dataverse Configuration")
|
||||
self._create_label_entry(scrollable_frame, 1, "Environment URL:", 'DATAVERSE_ENVIRONMENT_URL', width=60, multiline=True)
|
||||
self._create_label_entry(scrollable_frame, 2, "Table Name:", 'DATAVERSE_TABLE_NAME')
|
||||
|
||||
# Azure AD section
|
||||
self._create_section_header(scrollable_frame, 3, "🔐 Azure AD Configuration")
|
||||
self._create_label_entry(scrollable_frame, 4, "Client ID:", 'AZURE_AD_CLIENT_ID', width=60)
|
||||
self._create_label_entry(scrollable_frame, 5, "Client Secret:", 'AZURE_AD_CLIENT_SECRET', password=True, width=60)
|
||||
self._create_label_entry(scrollable_frame, 6, "Tenant ID:", 'AZURE_AD_TENANT_ID', width=60)
|
||||
|
||||
# Help text
|
||||
help_text = ttk.Label(scrollable_frame, text="\n💡 UUF Integration:\n"
|
||||
"• This section is only needed if you want to fetch UUF items\n"
|
||||
"• UUF items are processed differently than Azure DevOps work items\n"
|
||||
"• Environment URL: Your Dataverse environment\n"
|
||||
"• Azure AD app must have appropriate permissions\n"
|
||||
"• Contact your PowerApp administrator for these values\n"
|
||||
"• Leave blank if not using UUF integration",
|
||||
font=('Arial', 9), foreground='blue', justify=tk.LEFT, wraplength=800)
|
||||
help_text.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=20, padx=10)
|
||||
|
||||
# Pack canvas and scrollbar
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
# REMOVED: _create_dataverse_tab method
|
||||
# This was specific to Azure DevOps/Dataverse integration
|
||||
# def _create_dataverse_tab(self, notebook):
|
||||
# """Create Dataverse/PowerApp settings tab"""
|
||||
# ...
|
||||
|
||||
def _create_section_header(self, parent, row: int, text: str):
|
||||
"""Create a section header"""
|
||||
@@ -318,7 +281,49 @@ class SettingsDialog:
|
||||
|
||||
self.entries[config_key] = entry
|
||||
parent.columnconfigure(1, weight=1)
|
||||
|
||||
|
||||
def _create_target_repo_dropdown(self, parent, row: int):
|
||||
"""Create target repository dropdown with search functionality"""
|
||||
ttk.Label(parent, text="Target Repository:", font=('Arial', 10, 'bold')).grid(
|
||||
row=row, column=0, sticky=tk.W, pady=5, padx=10)
|
||||
|
||||
# Frame for dropdown, search entry, and buttons
|
||||
dropdown_frame = ttk.Frame(parent)
|
||||
dropdown_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
dropdown_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Placeholder for target repos
|
||||
self.target_repos = []
|
||||
|
||||
# Combobox for target repo (searchable)
|
||||
self.target_repo_var = tk.StringVar(value=self.config.get('GITHUB_REPO', ''))
|
||||
self.target_repo_dropdown = ttk.Combobox(dropdown_frame, textvariable=self.target_repo_var,
|
||||
values=[''], width=50)
|
||||
self.target_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
self.entries['GITHUB_REPO'] = self.target_repo_var
|
||||
|
||||
# Bind typing event for search
|
||||
self.target_repo_dropdown.bind('<KeyRelease>', self._on_target_repo_search)
|
||||
|
||||
# Refresh button
|
||||
refresh_btn = ttk.Button(dropdown_frame, text="🔄", width=3,
|
||||
command=self._refresh_target_repos)
|
||||
refresh_btn.grid(row=0, column=1, padx=(0, 2))
|
||||
|
||||
# Search button
|
||||
search_btn = ttk.Button(dropdown_frame, text="🔍", width=3,
|
||||
command=self._search_target_repos)
|
||||
search_btn.grid(row=0, column=2)
|
||||
|
||||
# Help text for target repo
|
||||
help_label = ttk.Label(parent,
|
||||
text=" ℹ️ Upstream repo where PRs will be created. Type to search all GitHub repos.",
|
||||
font=('Arial', 9), foreground='gray')
|
||||
help_label.grid(row=row+1, column=0, columnspan=3, sticky=tk.W, padx=10)
|
||||
|
||||
# Start async loading of user's repos with edit access
|
||||
self.dialog.after(100, self._load_target_repos_async)
|
||||
|
||||
def _create_forked_repo_dropdown(self, parent, row: int):
|
||||
"""Create forked repository dropdown with local repo detection"""
|
||||
ttk.Label(parent, text="Forked Repository:", font=('Arial', 10, 'bold')).grid(
|
||||
@@ -354,10 +359,15 @@ class SettingsDialog:
|
||||
self.entries['FORKED_REPO'] = self.forked_repo_var
|
||||
|
||||
# Refresh button
|
||||
refresh_btn = ttk.Button(dropdown_frame, text="🔄", width=3,
|
||||
refresh_btn = ttk.Button(dropdown_frame, text="🔄", width=3,
|
||||
command=self._refresh_forked_repos)
|
||||
refresh_btn.grid(row=0, column=1)
|
||||
|
||||
refresh_btn.grid(row=0, column=1, padx=(0, 2))
|
||||
|
||||
# Clone button
|
||||
clone_btn = ttk.Button(dropdown_frame, text="📥", width=3,
|
||||
command=self._clone_forked_repo)
|
||||
clone_btn.grid(row=0, column=2)
|
||||
|
||||
# Help text for forked repo
|
||||
help_label = ttk.Label(parent,
|
||||
text=" ℹ️ Your fork where changes will be made. Leave empty to auto-detect from document URL.",
|
||||
@@ -404,7 +414,128 @@ class SettingsDialog:
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error refreshing local repos: {e}")
|
||||
|
||||
|
||||
def _clone_forked_repo(self):
|
||||
"""Clone the selected forked repository to the local repo path"""
|
||||
# Get selected repository
|
||||
selected_repo = self.forked_repo_var.get().strip()
|
||||
|
||||
# Validate selection
|
||||
if not selected_repo:
|
||||
messagebox.showwarning("No Repository Selected",
|
||||
"Please select a repository to clone.")
|
||||
return
|
||||
|
||||
# Check if it's a section header
|
||||
if selected_repo.startswith('---'):
|
||||
messagebox.showwarning("Invalid Selection",
|
||||
"Please select a repository, not a section header.")
|
||||
return
|
||||
|
||||
# Get local repo path
|
||||
local_repo_path = self.config.get('LOCAL_REPO_PATH', '').strip()
|
||||
if not local_repo_path:
|
||||
messagebox.showwarning("Local Path Not Configured",
|
||||
"Please configure the Local Repository Path in settings first.")
|
||||
return
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
try:
|
||||
os.makedirs(local_repo_path, exist_ok=True)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Directory Error",
|
||||
f"Could not create local repository directory:\n{str(e)}")
|
||||
return
|
||||
|
||||
# Extract repo name (handle both "owner/repo" and URLs)
|
||||
repo_name = selected_repo
|
||||
if repo_name.startswith('http'):
|
||||
# Extract from URL
|
||||
parts = repo_name.rstrip('/').split('/')
|
||||
if len(parts) >= 2:
|
||||
repo_name = f"{parts[-2]}/{parts[-1]}"
|
||||
else:
|
||||
messagebox.showerror("Invalid Repository",
|
||||
"Could not parse repository name from URL.")
|
||||
return
|
||||
|
||||
# Validate format "owner/repo"
|
||||
if '/' not in repo_name:
|
||||
messagebox.showerror("Invalid Repository",
|
||||
"Repository must be in 'owner/repo' format.")
|
||||
return
|
||||
|
||||
# Extract just the repo name for the folder
|
||||
folder_name = repo_name.split('/')[-1]
|
||||
target_path = os.path.join(local_repo_path, folder_name)
|
||||
|
||||
# Check if directory already exists
|
||||
if os.path.exists(target_path):
|
||||
response = messagebox.askyesno("Directory Exists",
|
||||
f"The directory '{folder_name}' already exists.\n\n"
|
||||
f"Do you want to continue anyway?\n"
|
||||
f"(This may fail if it's already a git repository)")
|
||||
if not response:
|
||||
return
|
||||
|
||||
# Construct clone URL
|
||||
clone_url = f"https://github.com/{repo_name}.git"
|
||||
|
||||
# Clone in background thread
|
||||
def clone_repo():
|
||||
try:
|
||||
# Run git clone
|
||||
result = subprocess.run(
|
||||
['git', 'clone', clone_url, target_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 minute timeout
|
||||
)
|
||||
|
||||
# Update UI on main thread
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: self._handle_clone_result(result, repo_name, folder_name))
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"Clone Timeout",
|
||||
f"Cloning {repo_name} timed out after 5 minutes."))
|
||||
except FileNotFoundError:
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"Git Not Found",
|
||||
"Git is not installed or not found in PATH.\n\n"
|
||||
"Please install Git from: https://git-scm.com/downloads"))
|
||||
except Exception as e:
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"Clone Error",
|
||||
f"An error occurred while cloning:\n{str(e)}"))
|
||||
|
||||
# Show progress message
|
||||
messagebox.showinfo("Cloning Repository",
|
||||
f"Cloning {repo_name} to:\n{target_path}\n\n"
|
||||
f"This may take a few moments...")
|
||||
|
||||
# Start clone in background
|
||||
thread = threading.Thread(target=clone_repo, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _handle_clone_result(self, result, repo_name: str, folder_name: str):
|
||||
"""Handle the result of a git clone operation"""
|
||||
if result.returncode == 0:
|
||||
messagebox.showinfo("Clone Successful",
|
||||
f"Successfully cloned {repo_name}!\n\n"
|
||||
f"Location: {folder_name}/")
|
||||
# Refresh the dropdown to show the newly cloned repo
|
||||
self._refresh_forked_repos()
|
||||
else:
|
||||
error_msg = result.stderr if result.stderr else result.stdout
|
||||
messagebox.showerror("Clone Failed",
|
||||
f"Failed to clone {repo_name}.\n\n"
|
||||
f"Error:\n{error_msg}")
|
||||
|
||||
def _load_user_forks_async(self):
|
||||
"""Load user's GitHub forks asynchronously"""
|
||||
def load_forks():
|
||||
@@ -412,18 +543,19 @@ class SettingsDialog:
|
||||
github_token = self.config.get('GITHUB_PAT', '')
|
||||
if not github_token:
|
||||
return
|
||||
|
||||
from .github_api import GitHubGQL
|
||||
github_api = GitHubGQL(github_token, dry_run=False)
|
||||
self.forked_repos = github_api.get_user_forks()
|
||||
|
||||
|
||||
from .workflow import GitHubRepoFetcher
|
||||
repo_fetcher = GitHubRepoFetcher(github_token)
|
||||
repos = repo_fetcher.fetch_user_repos(repo_type='owner')
|
||||
self.forked_repos = repo_fetcher.get_repo_names(repos)
|
||||
|
||||
# Update dropdown on main thread
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, self._update_forked_dropdown)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading user forks: {e}")
|
||||
|
||||
|
||||
threading.Thread(target=load_forks, daemon=True).start()
|
||||
|
||||
def _update_forked_dropdown(self):
|
||||
@@ -436,22 +568,247 @@ class SettingsDialog:
|
||||
return
|
||||
|
||||
current_values = list(self.forked_repo_dropdown['values'])
|
||||
|
||||
# Remove old GitHub forks section if exists
|
||||
if '--- Your GitHub Forks ---' in current_values:
|
||||
start_idx = current_values.index('--- Your GitHub Forks ---')
|
||||
|
||||
# Remove old GitHub repos section if exists
|
||||
if '--- Your GitHub Repos ---' in current_values:
|
||||
start_idx = current_values.index('--- Your GitHub Repos ---')
|
||||
current_values = current_values[:start_idx]
|
||||
|
||||
# Add GitHub forks section
|
||||
|
||||
# Add GitHub repos section
|
||||
if self.forked_repos:
|
||||
current_values.append('--- Your GitHub Forks ---')
|
||||
current_values.append('--- Your GitHub Repos ---')
|
||||
current_values.extend(self.forked_repos)
|
||||
|
||||
self.forked_repo_dropdown['values'] = current_values
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating forked dropdown: {e}")
|
||||
|
||||
|
||||
def _load_target_repos_async(self):
|
||||
"""Load target repos (with push/admin access) asynchronously"""
|
||||
def load_repos():
|
||||
try:
|
||||
github_token = self.config.get('GITHUB_PAT', '')
|
||||
if not github_token:
|
||||
return
|
||||
|
||||
from .workflow import GitHubRepoFetcher
|
||||
repo_fetcher = GitHubRepoFetcher(github_token)
|
||||
repos = repo_fetcher.fetch_repos_with_permissions(min_permission='push')
|
||||
self.target_repos = repo_fetcher.get_repo_names(repos)
|
||||
|
||||
# Update dropdown on main thread
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, self._update_target_dropdown)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading target repos: {e}")
|
||||
|
||||
threading.Thread(target=load_repos, daemon=True).start()
|
||||
|
||||
def _update_target_dropdown(self):
|
||||
"""Update the target repository dropdown"""
|
||||
try:
|
||||
if not hasattr(self, 'dialog') or not self.dialog.winfo_exists():
|
||||
return
|
||||
if not hasattr(self, 'target_repo_dropdown') or not self.target_repo_dropdown.winfo_exists():
|
||||
return
|
||||
|
||||
current_values = [''] # Start with empty option
|
||||
|
||||
# Add user's repos with edit access
|
||||
if self.target_repos:
|
||||
current_values.append('--- Your Repos (with edit access) ---')
|
||||
current_values.extend(self.target_repos)
|
||||
|
||||
self.target_repo_dropdown['values'] = current_values
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating target dropdown: {e}")
|
||||
|
||||
def _refresh_target_repos(self):
|
||||
"""Refresh target repositories"""
|
||||
self._load_target_repos_async()
|
||||
|
||||
def _search_target_repos(self):
|
||||
"""Search for repositories on GitHub"""
|
||||
query = self.target_repo_var.get().strip()
|
||||
if not query:
|
||||
return
|
||||
|
||||
def search_repos():
|
||||
try:
|
||||
github_token = self.config.get('GITHUB_PAT', '')
|
||||
if not github_token:
|
||||
return
|
||||
|
||||
from .workflow import GitHubRepoFetcher
|
||||
repo_fetcher = GitHubRepoFetcher(github_token)
|
||||
repos = repo_fetcher.search_repositories(query, per_page=50)
|
||||
search_results = repo_fetcher.get_repo_names(repos)
|
||||
|
||||
# Update dropdown on main thread
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: self._update_target_dropdown_with_search(search_results, query))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching repos: {e}")
|
||||
|
||||
threading.Thread(target=search_repos, daemon=True).start()
|
||||
|
||||
def _update_target_dropdown_with_search(self, search_results, query):
|
||||
"""Update target dropdown with search results"""
|
||||
try:
|
||||
if not hasattr(self, 'target_repo_dropdown') or not self.target_repo_dropdown.winfo_exists():
|
||||
return
|
||||
|
||||
current_values = ['']
|
||||
|
||||
# Add user's repos
|
||||
if self.target_repos:
|
||||
current_values.append('--- Your Repos (with edit access) ---')
|
||||
current_values.extend(self.target_repos)
|
||||
|
||||
# Add search results
|
||||
if search_results:
|
||||
current_values.append(f'--- Search Results for "{query}" ---')
|
||||
current_values.extend(search_results)
|
||||
|
||||
self.target_repo_dropdown['values'] = current_values
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating target dropdown with search: {e}")
|
||||
|
||||
def _on_target_repo_search(self, _event):
|
||||
"""Handle typing in target repo field for auto-search"""
|
||||
# Debounce: only search after user stops typing for 500ms
|
||||
if hasattr(self, '_search_timer'):
|
||||
self.dialog.after_cancel(self._search_timer)
|
||||
|
||||
query = self.target_repo_var.get().strip()
|
||||
if len(query) >= 3: # Only search if at least 3 characters
|
||||
self._search_timer = self.dialog.after(500, self._search_target_repos)
|
||||
|
||||
def _create_ollama_model_dropdown(self, parent, row: int):
|
||||
"""Create Ollama model dropdown with scan button"""
|
||||
ttk.Label(parent, text="Ollama Model:", font=('Arial', 10, 'bold')).grid(
|
||||
row=row, column=0, sticky=tk.W, pady=5, padx=10)
|
||||
|
||||
# Frame for dropdown and scan button
|
||||
dropdown_frame = ttk.Frame(parent)
|
||||
dropdown_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
dropdown_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Model dropdown
|
||||
self.ollama_model_var = tk.StringVar(value=self.config.get('OLLAMA_MODEL', ''))
|
||||
self.ollama_model_dropdown = ttk.Combobox(dropdown_frame, textvariable=self.ollama_model_var,
|
||||
values=[''], width=47)
|
||||
self.ollama_model_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
self.entries['OLLAMA_MODEL'] = self.ollama_model_var
|
||||
|
||||
# Scan button
|
||||
scan_btn = ttk.Button(dropdown_frame, text="🔍", width=3,
|
||||
command=self._scan_ollama_models)
|
||||
scan_btn.grid(row=0, column=1)
|
||||
|
||||
# Help text for Ollama model
|
||||
help_label = ttk.Label(parent,
|
||||
text=" ℹ️ Click 🔍 to scan available models from your Ollama server.",
|
||||
font=('Arial', 9), foreground='gray')
|
||||
help_label.grid(row=row+1, column=0, columnspan=3, sticky=tk.W, padx=10)
|
||||
|
||||
def _scan_ollama_models(self):
|
||||
"""Scan Ollama server for available models"""
|
||||
ollama_url = self.entries.get('OLLAMA_URL').get().strip() if 'OLLAMA_URL' in self.entries else ''
|
||||
|
||||
if not ollama_url:
|
||||
messagebox.showwarning("Ollama URL Required",
|
||||
"Please enter the Ollama Server URL first.")
|
||||
return
|
||||
|
||||
# Normalize URL
|
||||
if not ollama_url.startswith('http'):
|
||||
ollama_url = f"http://{ollama_url}"
|
||||
|
||||
# Scan in background thread
|
||||
def scan_models():
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Get API key if provided
|
||||
ollama_api_key = self.entries.get('OLLAMA_API_KEY').get().strip() if 'OLLAMA_API_KEY' in self.entries else ''
|
||||
|
||||
headers = {}
|
||||
if ollama_api_key:
|
||||
headers['Authorization'] = f'Bearer {ollama_api_key}'
|
||||
|
||||
# Query Ollama API for models
|
||||
response = requests.get(f"{ollama_url}/api/tags", headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
models = data.get('models', [])
|
||||
model_names = [model.get('name', '') for model in models if model.get('name')]
|
||||
|
||||
# Update UI on main thread
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: self._update_ollama_models(model_names))
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"Connection Error",
|
||||
f"Could not connect to Ollama server at:\n{ollama_url}\n\n"
|
||||
f"Make sure Ollama is running and the URL is correct."))
|
||||
except requests.exceptions.Timeout:
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"Timeout",
|
||||
f"Connection to Ollama server timed out."))
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
if e.response.status_code == 401:
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"Authentication Error",
|
||||
"Invalid API key. Please check your Ollama API Key."))
|
||||
else:
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"HTTP Error",
|
||||
f"Error from Ollama server:\n{e}"))
|
||||
except Exception as e:
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, lambda: messagebox.showerror(
|
||||
"Scan Error",
|
||||
f"An error occurred while scanning for models:\n{str(e)}"))
|
||||
|
||||
# Start scan in background
|
||||
threading.Thread(target=scan_models, daemon=True).start()
|
||||
|
||||
def _update_ollama_models(self, model_names):
|
||||
"""Update the Ollama model dropdown with scanned models"""
|
||||
if not model_names:
|
||||
messagebox.showinfo("No Models Found",
|
||||
"No models found on the Ollama server.\n\n"
|
||||
"Use 'ollama pull <model>' to download models.")
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(self, 'ollama_model_dropdown') and self.ollama_model_dropdown.winfo_exists():
|
||||
current_value = self.ollama_model_var.get()
|
||||
self.ollama_model_dropdown['values'] = model_names
|
||||
|
||||
# Keep current selection if it's still in the list
|
||||
if current_value not in model_names and model_names:
|
||||
self.ollama_model_var.set(model_names[0])
|
||||
|
||||
messagebox.showinfo("Models Found",
|
||||
f"Found {len(model_names)} model(s):\n\n" +
|
||||
"\n".join(f"• {name}" for name in model_names[:10]) +
|
||||
(f"\n\n...and {len(model_names) - 10} more" if len(model_names) > 10 else ""))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating Ollama models: {e}")
|
||||
|
||||
def _create_dry_run_checkbox(self, parent, row: int):
|
||||
"""Create dry run checkbox"""
|
||||
self.dry_run_var = tk.BooleanVar()
|
||||
@@ -502,22 +859,9 @@ class SettingsDialog:
|
||||
|
||||
results = []
|
||||
|
||||
# Test Azure DevOps
|
||||
if config_values.get('AZURE_DEVOPS_QUERY') and config_values.get('AZURE_DEVOPS_PAT'):
|
||||
try:
|
||||
# Try to import and test Azure DevOps API
|
||||
from .azure_devops_api import AzureDevOpsAPI
|
||||
api = AzureDevOpsAPI(config_values.get('AZURE_DEVOPS_PAT'))
|
||||
|
||||
# Basic connection test (this would need actual implementation)
|
||||
results.append("Azure DevOps: ✅ Configuration looks valid")
|
||||
except ImportError:
|
||||
results.append("Azure DevOps: ⚠️ Configuration set (API module not available)")
|
||||
except Exception as e:
|
||||
results.append(f"Azure DevOps: ❌ Error - {str(e)}")
|
||||
elif config_values.get('AZURE_DEVOPS_QUERY') or config_values.get('AZURE_DEVOPS_PAT'):
|
||||
results.append("Azure DevOps: ⚠️ Incomplete configuration")
|
||||
|
||||
# REMOVED: Azure DevOps test connection
|
||||
# This was specific to Azure DevOps integration
|
||||
|
||||
# Test GitHub
|
||||
if config_values.get('GITHUB_PAT'):
|
||||
try:
|
||||
@@ -605,15 +949,15 @@ class SettingsDialog:
|
||||
config_values = self._get_config_values()
|
||||
|
||||
# Validate required fields
|
||||
required_for_basic = ['AZURE_DEVOPS_QUERY', 'AZURE_DEVOPS_PAT', 'GITHUB_PAT']
|
||||
required_for_basic = ['GITHUB_PAT']
|
||||
missing_basic = [field for field in required_for_basic if not config_values.get(field)]
|
||||
|
||||
|
||||
if missing_basic:
|
||||
messagebox.showwarning(
|
||||
"Missing Configuration",
|
||||
f"The following required fields are missing:\n\n"
|
||||
f"• {', '.join(missing_basic)}\n\n"
|
||||
f"These are required for basic functionality."
|
||||
f"GitHub Personal Access Token is required for basic functionality."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -686,7 +1030,7 @@ class SettingsDialog:
|
||||
import os
|
||||
|
||||
# Create .env content
|
||||
env_content = "# Azure DevOps to GitHub Tool Configuration\n"
|
||||
env_content = "# GitHub Pulse Configuration\n"
|
||||
env_content += "# Generated by Settings Dialog\n\n"
|
||||
|
||||
# Add all configuration values
|
||||
@@ -822,11 +1166,11 @@ class SettingsDialog:
|
||||
self.dialog.destroy()
|
||||
|
||||
def _clear_cache(self):
|
||||
"""Clear all cached work items"""
|
||||
"""Clear all cached items"""
|
||||
result = messagebox.askyesno(
|
||||
"Clear Cache",
|
||||
"Are you sure you want to clear all cached items?\n\n"
|
||||
"Cached work items and UUF items will be removed.\n"
|
||||
"All cached data will be removed.\n"
|
||||
"The next time you open the app, it will auto-load fresh data."
|
||||
)
|
||||
if result:
|
||||
|
||||
@@ -192,11 +192,11 @@ class GitHubInfoExtractor:
|
||||
|
||||
|
||||
class WorkItemFieldExtractor:
|
||||
"""Extracts and processes work item fields"""
|
||||
|
||||
"""Extracts and processes item fields (placeholder for future implementation)"""
|
||||
|
||||
@staticmethod
|
||||
def extract_work_item_fields(work_item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract and process fields from Azure DevOps work item"""
|
||||
"""Extract and process fields from work item (placeholder)"""
|
||||
fields = work_item.get('fields', {})
|
||||
|
||||
# Extract basic fields
|
||||
@@ -243,12 +243,12 @@ class WorkItemFieldExtractor:
|
||||
'new_text': new_text,
|
||||
'github_info': github_info,
|
||||
'status': 'Ready',
|
||||
'source': 'Azure DevOps'
|
||||
'source': 'Generic'
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def extract_uuf_item_fields(uuf_item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract and process fields from UUF item"""
|
||||
"""Extract and process fields from custom item (placeholder)"""
|
||||
# UUF items have different field structure
|
||||
item_id = uuf_item.get('cr_uufitemid', 'Unknown')
|
||||
title = uuf_item.get('cr_title', 'No Title')
|
||||
@@ -296,17 +296,18 @@ class ContentBuilders:
|
||||
@staticmethod
|
||||
def build_issue_title(item: Dict[str, Any]) -> str:
|
||||
"""Build GitHub issue title"""
|
||||
source_prefix = "UUF" if item.get('source') == 'UUF' else "AB"
|
||||
return f"[{source_prefix}#{item['id']}] {item['title']}"
|
||||
|
||||
item_id = item.get('id', '')
|
||||
if item_id:
|
||||
return f"[#{item_id}] {item['title']}"
|
||||
return f"{item['title']}"
|
||||
|
||||
@staticmethod
|
||||
def build_issue_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str:
|
||||
"""Build GitHub issue body"""
|
||||
body_parts = []
|
||||
|
||||
|
||||
# Header
|
||||
source_name = "UUF Item" if item.get('source') == 'UUF' else "Azure DevOps Work Item"
|
||||
body_parts.append(f"## {source_name} Details")
|
||||
body_parts.append("## Item Details")
|
||||
body_parts.append("")
|
||||
|
||||
# Make ID a hyperlink if source URL is available
|
||||
@@ -372,24 +373,25 @@ class ContentBuilders:
|
||||
body_parts.append("5. Close this issue when complete")
|
||||
body_parts.append("")
|
||||
body_parts.append("---")
|
||||
body_parts.append("*Created automatically by Azure DevOps → GitHub Processor*")
|
||||
|
||||
body_parts.append("*Created automatically by GitHub Pulse*")
|
||||
|
||||
return "\n".join(body_parts)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def build_pr_title(item: Dict[str, Any]) -> str:
|
||||
"""Build GitHub PR title"""
|
||||
source_prefix = "UUF" if item.get('source') == 'UUF' else "AB"
|
||||
return f"[{source_prefix}#{item['id']}] {item['title']}"
|
||||
item_id = item.get('id', '')
|
||||
if item_id:
|
||||
return f"[#{item_id}] {item['title']}"
|
||||
return f"{item['title']}"
|
||||
|
||||
@staticmethod
|
||||
def build_pr_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str:
|
||||
"""Build GitHub PR body"""
|
||||
body_parts = []
|
||||
|
||||
|
||||
# Header
|
||||
source_name = "UUF Item" if item.get('source') == 'UUF' else "Azure DevOps Work Item"
|
||||
body_parts.append(f"## {source_name} Documentation Update")
|
||||
body_parts.append("## Documentation Update")
|
||||
body_parts.append("")
|
||||
|
||||
# Make ID a hyperlink if source URL is available
|
||||
@@ -446,10 +448,10 @@ class ContentBuilders:
|
||||
body_parts.append("- [ ] Grammar and formatting are correct")
|
||||
body_parts.append("- [ ] Links and references are working")
|
||||
body_parts.append("")
|
||||
|
||||
|
||||
body_parts.append("---")
|
||||
body_parts.append("*Created automatically by Azure DevOps → GitHub Processor*")
|
||||
|
||||
body_parts.append("*Created automatically by GitHub Pulse*")
|
||||
|
||||
return "\n".join(body_parts)
|
||||
|
||||
|
||||
@@ -612,17 +614,14 @@ class ConfigurationHelpers:
|
||||
def create_default_env_file() -> bool:
|
||||
"""Create a default .env file with all settings blank"""
|
||||
try:
|
||||
default_config = """# Azure DevOps to GitHub Tool Configuration
|
||||
default_config = """# GitHub Pulse Configuration
|
||||
# Generated automatically - fill in your values
|
||||
# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore.
|
||||
|
||||
# Azure DevOps Configuration
|
||||
AZURE_DEVOPS_QUERY=
|
||||
AZURE_DEVOPS_PAT=
|
||||
|
||||
# GitHub Configuration
|
||||
GITHUB_PAT=
|
||||
GITHUB_REPO=
|
||||
FORKED_REPO=
|
||||
|
||||
# Application Settings
|
||||
DRY_RUN=false
|
||||
@@ -634,12 +633,8 @@ OPENAI_API_KEY=
|
||||
GITHUB_TOKEN=
|
||||
LOCAL_REPO_PATH=
|
||||
|
||||
# PowerApp/Dataverse Configuration (for UUF items - optional)
|
||||
DATAVERSE_ENVIRONMENT_URL=
|
||||
DATAVERSE_TABLE_NAME=
|
||||
AZURE_AD_CLIENT_ID=
|
||||
AZURE_AD_CLIENT_SECRET=
|
||||
AZURE_AD_TENANT_ID=
|
||||
# Custom AI Instructions (optional)
|
||||
CUSTOM_INSTRUCTIONS=
|
||||
"""
|
||||
with open('.env', 'w', encoding='utf-8') as f:
|
||||
f.write(default_config)
|
||||
@@ -652,78 +647,8 @@ AZURE_AD_TENANT_ID=
|
||||
return False
|
||||
|
||||
|
||||
class EnhancedContentBuilders(ContentBuilders):
|
||||
"""Enhanced content builders with Azure DevOps specific methods"""
|
||||
|
||||
@staticmethod
|
||||
def build_pr_title_for_azure_devops(item: Dict[str, Any]) -> str:
|
||||
"""Build GitHub PR title for Azure DevOps items"""
|
||||
return f"Docs update: {item['title'][:80]} (AB#{item['id']})"
|
||||
|
||||
@staticmethod
|
||||
def build_pr_body_for_azure_devops(item: Dict[str, Any], github_info: Dict[str, Any]) -> str:
|
||||
"""Build GitHub PR body for Azure DevOps items with enhanced Copilot instructions"""
|
||||
now = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
lines = [
|
||||
f"**Automated documentation update from Azure DevOps (created on {now})**",
|
||||
"",
|
||||
f"**Work Item ID:** AB#{item['id']}",
|
||||
f"**Document URL:** {item['mydoc_url']}",
|
||||
]
|
||||
|
||||
# Add file path information if available
|
||||
if github_info.get('original_content_git_url'):
|
||||
lines.append(f"**File Path:** {github_info['original_content_git_url']}")
|
||||
|
||||
# Add ms.author metadata if available
|
||||
if github_info.get('ms_author'):
|
||||
lines.append(f"**ms.author:** `{github_info['ms_author']}`")
|
||||
|
||||
# Add nature of request for context
|
||||
lines.extend([
|
||||
"",
|
||||
"## Change Type",
|
||||
f"{item['nature_of_request']}",
|
||||
"",
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"## Changes Requested",
|
||||
"",
|
||||
"### Current Text to Replace",
|
||||
"```",
|
||||
item['text_to_change'],
|
||||
"```",
|
||||
"",
|
||||
"### Proposed New Text",
|
||||
"```",
|
||||
item['new_text'],
|
||||
"```",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Instructions for GitHub Copilot",
|
||||
"",
|
||||
"**Task:** Update the documentation file with the changes requested above.",
|
||||
"",
|
||||
"**Steps to complete:**",
|
||||
"1. Locate the file containing the 'Current Text to Replace' shown above",
|
||||
"2. Find the exact text that needs to be updated",
|
||||
"3. Replace it with the 'Proposed New Text'",
|
||||
"4. Ensure no other changes are made to the file",
|
||||
"5. Commit the changes with a descriptive message",
|
||||
"",
|
||||
"**Important Notes:**",
|
||||
"- Only change the specific text shown above",
|
||||
"- Do not modify formatting, links, or other content",
|
||||
"- Verify the replacement text fits naturally in context",
|
||||
"",
|
||||
"---",
|
||||
"*This PR was created automatically from Azure DevOps work item AB#" + str(item['id']) + "*"
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
# Removed EnhancedContentBuilders class - was specific to Azure DevOps
|
||||
# Use ContentBuilders class for generic GitHub automation instead
|
||||
|
||||
|
||||
# Compatibility functions for direct function access
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
Work Item Processor
|
||||
Handles processing of Azure DevOps work items and UUF items
|
||||
"""
|
||||
|
||||
import re
|
||||
import html
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
from .utils import WorkItemFieldExtractor
|
||||
|
||||
# User agent for web requests
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
|
||||
|
||||
class WorkItemProcessor:
|
||||
"""Processor for extracting and validating work item data with advanced parsing"""
|
||||
|
||||
def __init__(self, logger, config: Dict[str, Any] = None):
|
||||
self.logger = logger
|
||||
self.log = logger.log if hasattr(logger, 'log') else logger
|
||||
self.config = config or {}
|
||||
|
||||
def process_work_item(self, work_item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Process a single work item to extract required fields with advanced validation"""
|
||||
try:
|
||||
work_item_id = work_item['id']
|
||||
title = work_item.get('fields', {}).get('System.Title', 'No Title')
|
||||
description = work_item.get('fields', {}).get('System.Description', '')
|
||||
|
||||
if not description:
|
||||
self.log(f"Work item {work_item_id} has no description, skipping")
|
||||
return None
|
||||
|
||||
# Parse description for required fields
|
||||
parsed_data = self._parse_description(description)
|
||||
|
||||
if not parsed_data:
|
||||
self.log(f"Work item {work_item_id} doesn't contain required fields, skipping")
|
||||
return None
|
||||
|
||||
# Validate nature of request (check for both variations)
|
||||
nature_lower = parsed_data['nature_of_request'].lower()
|
||||
if not ("modify existing docs" in nature_lower or "modifying existing docs" in nature_lower):
|
||||
self.log(f"Work item {work_item_id} nature of request doesn't contain 'modify existing docs', skipping")
|
||||
return None
|
||||
|
||||
# Extract GitHub info from document URL
|
||||
github_info = self._extract_github_info(parsed_data['mydoc_url'])
|
||||
|
||||
# If the document does not include an original_content_git_url, skip this work item
|
||||
if not github_info.get('original_content_git_url'):
|
||||
self.log(f"Work item {work_item_id} skipped: original_content_git_url not found in document {parsed_data['mydoc_url']}")
|
||||
return None
|
||||
|
||||
# Construct proper web URL for work item
|
||||
# The API returns something like: https://dev.azure.com/org/project/_apis/wit/workItems/123
|
||||
# We need to convert it to: https://dev.azure.com/org/project/_workitems/edit/123
|
||||
work_item_url = ''
|
||||
api_url = work_item.get('url', '')
|
||||
if api_url:
|
||||
# Convert API URL to web URL
|
||||
# Replace /_apis/wit/workItems/ with /_workitems/edit/
|
||||
work_item_url = api_url.replace('/_apis/wit/workItems/', '/_workitems/edit/')
|
||||
|
||||
processed_item = {
|
||||
'id': work_item_id,
|
||||
'title': title,
|
||||
'nature_of_request': parsed_data['nature_of_request'],
|
||||
'mydoc_url': parsed_data['mydoc_url'],
|
||||
'text_to_change': parsed_data['text_to_change'],
|
||||
'new_text': parsed_data['new_text'],
|
||||
'github_info': github_info,
|
||||
'status': 'Ready',
|
||||
'source': 'Azure DevOps',
|
||||
'source_url': work_item_url, # URL to Azure DevOps work item
|
||||
'original_new_text': parsed_data['new_text'] # Keep original for reference
|
||||
}
|
||||
|
||||
self.log(f"Successfully processed work item {work_item_id}")
|
||||
return processed_item
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error processing work item {work_item.get('id', 'unknown')}: {str(e)}")
|
||||
return None
|
||||
|
||||
def process_uuf_item(self, uuf_item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Process a single UUF item from Dataverse/PowerApp with enhanced field mapping"""
|
||||
try:
|
||||
# Extract UUF item ID (adjust field name as needed)
|
||||
uuf_id = uuf_item.get('cr4af_uufid') or uuf_item.get('cr4af_name') or 'unknown'
|
||||
|
||||
# Extract title
|
||||
title = uuf_item.get('cr4af_title') or uuf_item.get('cr4af_subject') or 'No Title'
|
||||
|
||||
# Extract description/details
|
||||
description = uuf_item.get('cr4af_description') or uuf_item.get('cr4af_details') or ''
|
||||
|
||||
if not description:
|
||||
self.log(f"UUF item {uuf_id} has no description, skipping")
|
||||
return None
|
||||
|
||||
# Extract document URL
|
||||
doc_url = uuf_item.get('cr4af_documenturl') or uuf_item.get('cr4af_docurl') or ''
|
||||
|
||||
if not doc_url:
|
||||
self.log(f"UUF item {uuf_id} has no document URL, skipping")
|
||||
return None
|
||||
|
||||
# Extract text to change and new text
|
||||
text_to_change = uuf_item.get('cr4af_texttochange') or uuf_item.get('cr4af_currenttext') or ''
|
||||
new_text = uuf_item.get('cr4af_proposednewtext') or uuf_item.get('cr4af_newtext') or ''
|
||||
|
||||
if not text_to_change or not new_text:
|
||||
self.log(f"UUF item {uuf_id} missing text fields, skipping")
|
||||
return None
|
||||
|
||||
# Extract GitHub info from document URL
|
||||
github_info = self._extract_github_info(doc_url)
|
||||
|
||||
# If the document does not include an original_content_git_url, skip this item
|
||||
if not github_info.get('original_content_git_url'):
|
||||
self.log(f"UUF item {uuf_id} skipped: original_content_git_url not found in document {doc_url}")
|
||||
return None
|
||||
|
||||
# Get UUF item URL if available (e.g., from Dataverse)
|
||||
uuf_url = uuf_item.get('cr4af_itemurl', '') or uuf_item.get('cr4af_url', '')
|
||||
|
||||
processed_item = {
|
||||
'id': uuf_id,
|
||||
'title': title,
|
||||
'nature_of_request': 'UUF Item - Modify existing docs',
|
||||
'mydoc_url': doc_url,
|
||||
'text_to_change': text_to_change,
|
||||
'new_text': new_text,
|
||||
'github_info': github_info,
|
||||
'status': 'Ready',
|
||||
'source': 'UUF', # Mark as UUF item
|
||||
'source_url': uuf_url, # URL to UUF item (if available)
|
||||
'original_new_text': new_text
|
||||
}
|
||||
|
||||
self.log(f"Successfully processed UUF item {uuf_id}")
|
||||
return processed_item
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error processing UUF item {uuf_item.get('cr4af_uufid', 'unknown')}: {str(e)}")
|
||||
return None
|
||||
|
||||
def _parse_description(self, description: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse work item description to extract required fields using enhanced regex patterns"""
|
||||
# Enhanced regex patterns from regex_V5
|
||||
patterns = {
|
||||
'nature_of_request': r'nature\s+of\s+request[:\s]*([^\)]*\))',
|
||||
'link_to_doc': r'link\s+to\s+doc[:\s]*([^\s&]+)',
|
||||
'text_to_change': r'text\s+to\s+change[:\s]*([\s\S]*?)(?=\n*-+\s*Proposed new text|If adding brand new docs:|$)',
|
||||
'proposed_new_text': r'proposed\s+new\s+text[:\s]*([\s\S]+?)(?=\s*If\s+adding\s+brand\s+new\s+docs:)'
|
||||
}
|
||||
|
||||
# Clean HTML tags if present
|
||||
clean_description = re.sub(r'<[^>]+>', '', description)
|
||||
|
||||
# Convert HTML entities to characters (e.g., " to ", & to &)
|
||||
clean_description = html.unescape(clean_description)
|
||||
|
||||
extracted = {}
|
||||
for field, pattern in patterns.items():
|
||||
match = re.search(pattern, clean_description, re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
value = match.group(1).strip()
|
||||
|
||||
if field == 'nature_of_request':
|
||||
extracted['nature_of_request'] = value
|
||||
elif field == 'link_to_doc':
|
||||
extracted['mydoc_url'] = value.rstrip('-')
|
||||
elif field == 'text_to_change':
|
||||
extracted['text_to_change'] = value
|
||||
elif field == 'proposed_new_text':
|
||||
extracted['new_text'] = value
|
||||
|
||||
# If enhanced patterns don't work, fall back to basic patterns
|
||||
if not all(field in extracted for field in ['nature_of_request', 'mydoc_url', 'text_to_change', 'new_text']):
|
||||
basic_patterns = {
|
||||
'nature_of_request': r'nature\s+of\s+request[:\s]*([^\n]+)',
|
||||
'link_to_doc': r'link\s+to\s+doc[:\s]*([^\s]+)',
|
||||
'text_to_change': r'text\s+to\s+change[:\s]*(.+?)(?=proposed\s+new\s+text|$)',
|
||||
'proposed_new_text': r'proposed\s+new\s+text[:\s]*(.+?)(?=\n\n|$)'
|
||||
}
|
||||
|
||||
extracted = {}
|
||||
for field, pattern in basic_patterns.items():
|
||||
match = re.search(pattern, clean_description, re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
value = match.group(1).strip()
|
||||
|
||||
if field == 'nature_of_request':
|
||||
extracted['nature_of_request'] = value
|
||||
elif field == 'link_to_doc':
|
||||
extracted['mydoc_url'] = value
|
||||
elif field == 'text_to_change':
|
||||
extracted['text_to_change'] = value
|
||||
elif field == 'proposed_new_text':
|
||||
extracted['new_text'] = value
|
||||
|
||||
# Validate all required fields are present
|
||||
required_fields = ['nature_of_request', 'mydoc_url', 'text_to_change', 'new_text']
|
||||
if not all(field in extracted for field in required_fields):
|
||||
return None
|
||||
|
||||
return extracted
|
||||
|
||||
def _extract_github_info(self, doc_url: str) -> Dict[str, Any]:
|
||||
"""Extract GitHub repository info and ms.author from document URL
|
||||
|
||||
If GITHUB_REPO is configured in .env, it will be used instead of the repo
|
||||
extracted from the document metadata. This allows you to create PRs in your
|
||||
fork while preserving the file path and ms.author from the original document.
|
||||
"""
|
||||
try:
|
||||
# Fetch the document
|
||||
headers = {'User-Agent': USER_AGENT}
|
||||
response = requests.get(doc_url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
html_content = response.text
|
||||
|
||||
# Extract ms.author
|
||||
ms_author = self._extract_meta_tag(html_content, 'ms.author')
|
||||
|
||||
# Extract original_content_git_url
|
||||
original_content_git_url = self._extract_meta_tag(html_content, 'original_content_git_url')
|
||||
|
||||
if not original_content_git_url:
|
||||
# Try alternative extraction method
|
||||
match = re.search(r"original_content_git_url[\"\']?\s*[:=]\s*[\"\']([^\"']+)[\"']", html_content, re.IGNORECASE)
|
||||
if match:
|
||||
original_content_git_url = match.group(1).strip()
|
||||
|
||||
if not original_content_git_url:
|
||||
raise ValueError("original_content_git_url not found in document")
|
||||
|
||||
# Check if GITHUB_REPO is configured in .env
|
||||
# If it is, use that instead of the repo from the document
|
||||
configured_repo = self.config.get('GITHUB_REPO')
|
||||
|
||||
if configured_repo and '/' in configured_repo:
|
||||
# Use the configured repository (e.g., "b-tsammons/fabric-docs-pr")
|
||||
parts = configured_repo.split('/', 1)
|
||||
owner = parts[0].strip()
|
||||
repo = parts[1].strip()
|
||||
self.log(f"Using configured GITHUB_REPO: {owner}/{repo} (overriding document metadata)")
|
||||
else:
|
||||
# Parse GitHub owner/repo from original_content_git_url (fallback to document metadata)
|
||||
owner, repo = self._parse_github_url(original_content_git_url)
|
||||
self.log(f"Using repository from document metadata: {owner}/{repo}")
|
||||
|
||||
return {
|
||||
'ms_author': ms_author,
|
||||
'original_content_git_url': original_content_git_url,
|
||||
'owner': owner,
|
||||
'repo': repo
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error extracting GitHub info from {doc_url}: {str(e)}")
|
||||
return {
|
||||
'ms_author': None,
|
||||
'original_content_git_url': None,
|
||||
'owner': None,
|
||||
'repo': None,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _extract_meta_tag(self, html_content: str, name: str) -> Optional[str]:
|
||||
"""Extract content from meta tag"""
|
||||
pattern = rf'<meta\s+(?:[^>]*?\s)?(?:name|property)\s*=\s*["\'](?P<n>{re.escape(name)})["\']\s+[^>]*?\bcontent\s*=\s*["\'](?P<content>[^"\']+)["\'][^>]*?>'
|
||||
match = re.search(pattern, html_content, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group('content').strip()
|
||||
return None
|
||||
|
||||
def _parse_github_url(self, url: str) -> Tuple[str, str]:
|
||||
"""Parse GitHub URL to extract owner and repo"""
|
||||
parsed = urlparse(url)
|
||||
if "github.com" not in parsed.netloc.lower():
|
||||
raise ValueError(f"Not a GitHub URL: {url}")
|
||||
parts = [p for p in parsed.path.split("/") if p]
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Unable to parse owner/repo from: {url}")
|
||||
return parts[0], parts[1]
|
||||
@@ -0,0 +1,518 @@
|
||||
"""
|
||||
Workflow Manager
|
||||
Manages GitHub workflow items (Issues and Pull Requests) from target and fork repositories
|
||||
"""
|
||||
|
||||
import requests
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
|
||||
class WorkflowItem:
|
||||
"""Represents a GitHub workflow item (Issue or PR)"""
|
||||
|
||||
def __init__(self, item_type: str, data: Dict[str, Any], repo_source: str):
|
||||
"""
|
||||
Initialize a workflow item
|
||||
|
||||
Args:
|
||||
item_type: 'issue' or 'pull_request'
|
||||
data: Raw data from GitHub API
|
||||
repo_source: 'target' or 'fork'
|
||||
"""
|
||||
self.item_type = item_type
|
||||
self.repo_source = repo_source
|
||||
self.data = data
|
||||
|
||||
# Extract common fields
|
||||
self.number = data.get('number')
|
||||
self.title = data.get('title', 'No Title')
|
||||
self.state = data.get('state', 'unknown')
|
||||
self.created_at = data.get('created_at', '')
|
||||
self.updated_at = data.get('updated_at', '')
|
||||
self.body = data.get('body', '')
|
||||
self.url = data.get('html_url', '')
|
||||
self.api_url = data.get('url', '')
|
||||
|
||||
# Author information
|
||||
user = data.get('user', {})
|
||||
self.author = user.get('login', 'unknown') if user else 'unknown'
|
||||
self.author_url = user.get('html_url', '') if user else ''
|
||||
|
||||
# Labels
|
||||
self.labels = [label.get('name', '') for label in data.get('labels', [])]
|
||||
|
||||
# Assignees
|
||||
assignees = data.get('assignees', [])
|
||||
self.assignees = [a.get('login', '') for a in assignees if a]
|
||||
|
||||
# PR-specific fields
|
||||
if item_type == 'pull_request':
|
||||
self.is_draft = data.get('draft', False)
|
||||
self.mergeable_state = data.get('mergeable_state', 'unknown')
|
||||
self.merged = data.get('merged', False)
|
||||
self.base_ref = data.get('base', {}).get('ref', '')
|
||||
self.head_ref = data.get('head', {}).get('ref', '')
|
||||
else:
|
||||
self.is_draft = False
|
||||
self.mergeable_state = None
|
||||
self.merged = False
|
||||
self.base_ref = None
|
||||
self.head_ref = None
|
||||
|
||||
# Comments count
|
||||
self.comments_count = data.get('comments', 0)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<WorkflowItem {self.item_type} #{self.number}: {self.title[:50]}>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for easy serialization"""
|
||||
return {
|
||||
'item_type': self.item_type,
|
||||
'repo_source': self.repo_source,
|
||||
'number': self.number,
|
||||
'title': self.title,
|
||||
'state': self.state,
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
'body': self.body,
|
||||
'url': self.url,
|
||||
'author': self.author,
|
||||
'author_url': self.author_url,
|
||||
'labels': self.labels,
|
||||
'assignees': self.assignees,
|
||||
'is_draft': self.is_draft,
|
||||
'mergeable_state': self.mergeable_state,
|
||||
'merged': self.merged,
|
||||
'base_ref': self.base_ref,
|
||||
'head_ref': self.head_ref,
|
||||
'comments_count': self.comments_count
|
||||
}
|
||||
|
||||
|
||||
class GitHubRepoFetcher:
|
||||
"""Fetches repository information from GitHub"""
|
||||
|
||||
def __init__(self, github_token: str, logger=None):
|
||||
"""
|
||||
Initialize the repo fetcher
|
||||
|
||||
Args:
|
||||
github_token: GitHub Personal Access Token
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.token = github_token
|
||||
self.logger = logger
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {github_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "github-automation-tool/1.0"
|
||||
}
|
||||
|
||||
def log(self, message: str):
|
||||
"""Log a message"""
|
||||
if self.logger:
|
||||
self.logger.log(message)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
def get_authenticated_user(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about the authenticated user
|
||||
|
||||
Returns:
|
||||
Dictionary with user information or None if error
|
||||
"""
|
||||
try:
|
||||
url = "https://api.github.com/user"
|
||||
response = requests.get(url, headers=self.headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error fetching authenticated user: {str(e)}")
|
||||
return None
|
||||
|
||||
def fetch_user_repos(self, repo_type: str = 'owner', per_page: int = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch repositories for the authenticated user
|
||||
|
||||
Args:
|
||||
repo_type: 'owner', 'member', or 'all'
|
||||
per_page: Number of repos per page (max 100)
|
||||
|
||||
Returns:
|
||||
List of repository dictionaries
|
||||
"""
|
||||
try:
|
||||
url = "https://api.github.com/user/repos"
|
||||
params = {
|
||||
'type': repo_type,
|
||||
'per_page': min(per_page, 100),
|
||||
'sort': 'updated',
|
||||
'direction': 'desc'
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
repos = response.json()
|
||||
self.log(f"✅ Found {len(repos)} repositories ({repo_type})")
|
||||
return repos
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error fetching user repos: {str(e)}")
|
||||
return []
|
||||
|
||||
def fetch_repos_with_permissions(self, min_permission: str = 'push') -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch repositories where user has specific permissions
|
||||
|
||||
Args:
|
||||
min_permission: Minimum permission level ('pull', 'push', 'admin')
|
||||
|
||||
Returns:
|
||||
List of repository dictionaries with sufficient permissions
|
||||
"""
|
||||
try:
|
||||
# Fetch all repos user has access to
|
||||
all_repos = self.fetch_user_repos(repo_type='all')
|
||||
|
||||
# Filter by permission level
|
||||
filtered_repos = []
|
||||
permission_levels = {'pull': 0, 'push': 1, 'admin': 2}
|
||||
min_level = permission_levels.get(min_permission, 1)
|
||||
|
||||
for repo in all_repos:
|
||||
permissions = repo.get('permissions', {})
|
||||
|
||||
# Check permission level
|
||||
if permissions.get('admin'):
|
||||
level = 2
|
||||
elif permissions.get('push'):
|
||||
level = 1
|
||||
elif permissions.get('pull'):
|
||||
level = 0
|
||||
else:
|
||||
level = -1
|
||||
|
||||
if level >= min_level:
|
||||
filtered_repos.append(repo)
|
||||
|
||||
self.log(f"✅ Found {len(filtered_repos)} repos with '{min_permission}' permission or higher")
|
||||
return filtered_repos
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error fetching repos with permissions: {str(e)}")
|
||||
return []
|
||||
|
||||
def search_repositories(self, query: str, per_page: int = 30) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for repositories on GitHub
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
per_page: Number of results per page (max 100)
|
||||
|
||||
Returns:
|
||||
List of repository dictionaries
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
url = "https://api.github.com/search/repositories"
|
||||
params = {
|
||||
'q': query.strip(),
|
||||
'per_page': min(per_page, 100),
|
||||
'sort': 'updated',
|
||||
'order': 'desc'
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
repos = data.get('items', [])
|
||||
total_count = data.get('total_count', 0)
|
||||
|
||||
self.log(f"✅ Search found {total_count} repositories (showing {len(repos)})")
|
||||
return repos
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error searching repositories: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_repo_names(self, repos: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Extract repository names in 'owner/repo' format
|
||||
|
||||
Args:
|
||||
repos: List of repository dictionaries
|
||||
|
||||
Returns:
|
||||
List of repository name strings
|
||||
"""
|
||||
return [repo.get('full_name', '') for repo in repos if repo.get('full_name')]
|
||||
|
||||
|
||||
class WorkflowManager:
|
||||
"""Manages workflow items from GitHub repositories"""
|
||||
|
||||
def __init__(self, github_token: str, logger=None):
|
||||
"""
|
||||
Initialize the workflow manager
|
||||
|
||||
Args:
|
||||
github_token: GitHub Personal Access Token
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.token = github_token
|
||||
self.logger = logger
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {github_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "github-automation-tool/1.0"
|
||||
}
|
||||
# Initialize repo fetcher
|
||||
self.repo_fetcher = GitHubRepoFetcher(github_token, logger)
|
||||
|
||||
def log(self, message: str):
|
||||
"""Log a message"""
|
||||
if self.logger:
|
||||
self.logger.log(message)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
def _parse_repo(self, repo_str: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Parse a repository string into owner and name
|
||||
|
||||
Args:
|
||||
repo_str: Repository string in format "owner/repo"
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo) or None if invalid
|
||||
"""
|
||||
if not repo_str or '/' not in repo_str:
|
||||
return None
|
||||
|
||||
parts = repo_str.strip().split('/')
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
return parts[0], parts[1]
|
||||
|
||||
def fetch_issues(self, repo_str: str, repo_source: str = 'target',
|
||||
state: str = 'all', per_page: int = 100) -> List[WorkflowItem]:
|
||||
"""
|
||||
Fetch issues from a repository
|
||||
|
||||
Args:
|
||||
repo_str: Repository string in format "owner/repo"
|
||||
repo_source: 'target' or 'fork' to identify source
|
||||
state: 'open', 'closed', or 'all'
|
||||
per_page: Number of items per page (max 100)
|
||||
|
||||
Returns:
|
||||
List of WorkflowItem objects
|
||||
"""
|
||||
parsed = self._parse_repo(repo_str)
|
||||
if not parsed:
|
||||
self.log(f"L Invalid repository format: {repo_str}")
|
||||
return []
|
||||
|
||||
owner, repo = parsed
|
||||
self.log(f"Fetching issues from {owner}/{repo} ({repo_source})...")
|
||||
|
||||
try:
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
|
||||
params = {
|
||||
'state': state,
|
||||
'per_page': min(per_page, 100),
|
||||
'sort': 'updated',
|
||||
'direction': 'desc'
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
items_data = response.json()
|
||||
|
||||
# Filter out pull requests (GitHub's issues endpoint includes PRs)
|
||||
issues_data = [item for item in items_data if 'pull_request' not in item]
|
||||
|
||||
issues = [WorkflowItem('issue', data, repo_source) for data in issues_data]
|
||||
|
||||
self.log(f" Found {len(issues)} issues in {owner}/{repo}")
|
||||
return issues
|
||||
|
||||
except requests.HTTPError as e:
|
||||
self.log(f"L HTTP Error fetching issues from {owner}/{repo}: {e}")
|
||||
if e.response.status_code == 401:
|
||||
self.log(" Check your GitHub Personal Access Token")
|
||||
elif e.response.status_code == 404:
|
||||
self.log(" Repository not found or no access")
|
||||
return []
|
||||
except Exception as e:
|
||||
self.log(f"L Error fetching issues from {owner}/{repo}: {str(e)}")
|
||||
return []
|
||||
|
||||
def fetch_pull_requests(self, repo_str: str, repo_source: str = 'target',
|
||||
state: str = 'all', per_page: int = 100) -> List[WorkflowItem]:
|
||||
"""
|
||||
Fetch pull requests from a repository
|
||||
|
||||
Args:
|
||||
repo_str: Repository string in format "owner/repo"
|
||||
repo_source: 'target' or 'fork' to identify source
|
||||
state: 'open', 'closed', or 'all'
|
||||
per_page: Number of items per page (max 100)
|
||||
|
||||
Returns:
|
||||
List of WorkflowItem objects
|
||||
"""
|
||||
parsed = self._parse_repo(repo_str)
|
||||
if not parsed:
|
||||
self.log(f"L Invalid repository format: {repo_str}")
|
||||
return []
|
||||
|
||||
owner, repo = parsed
|
||||
self.log(f"Fetching pull requests from {owner}/{repo} ({repo_source})...")
|
||||
|
||||
try:
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/pulls"
|
||||
params = {
|
||||
'state': state,
|
||||
'per_page': min(per_page, 100),
|
||||
'sort': 'updated',
|
||||
'direction': 'desc'
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=self.headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
prs_data = response.json()
|
||||
prs = [WorkflowItem('pull_request', data, repo_source) for data in prs_data]
|
||||
|
||||
self.log(f" Found {len(prs)} pull requests in {owner}/{repo}")
|
||||
return prs
|
||||
|
||||
except requests.HTTPError as e:
|
||||
self.log(f"L HTTP Error fetching PRs from {owner}/{repo}: {e}")
|
||||
if e.response.status_code == 401:
|
||||
self.log(" Check your GitHub Personal Access Token")
|
||||
elif e.response.status_code == 404:
|
||||
self.log(" Repository not found or no access")
|
||||
return []
|
||||
except Exception as e:
|
||||
self.log(f"L Error fetching PRs from {owner}/{repo}: {str(e)}")
|
||||
return []
|
||||
|
||||
def fetch_all_workflow_items(self, target_repo: str, fork_repo: str = None,
|
||||
include_issues: bool = True,
|
||||
include_prs: bool = True,
|
||||
state: str = 'all') -> Dict[str, List[WorkflowItem]]:
|
||||
"""
|
||||
Fetch all workflow items from both target and fork repositories
|
||||
|
||||
Args:
|
||||
target_repo: Target repository string "owner/repo"
|
||||
fork_repo: Fork repository string "owner/repo" (optional)
|
||||
include_issues: Whether to fetch issues
|
||||
include_prs: Whether to fetch pull requests
|
||||
state: 'open', 'closed', or 'all'
|
||||
|
||||
Returns:
|
||||
Dictionary with keys 'target_issues', 'target_prs', 'fork_issues', 'fork_prs'
|
||||
"""
|
||||
results = {
|
||||
'target_issues': [],
|
||||
'target_prs': [],
|
||||
'fork_issues': [],
|
||||
'fork_prs': []
|
||||
}
|
||||
|
||||
# Fetch from target repository
|
||||
if target_repo:
|
||||
if include_issues:
|
||||
results['target_issues'] = self.fetch_issues(target_repo, 'target', state)
|
||||
if include_prs:
|
||||
results['target_prs'] = self.fetch_pull_requests(target_repo, 'target', state)
|
||||
|
||||
# Fetch from fork repository
|
||||
if fork_repo:
|
||||
if include_issues:
|
||||
results['fork_issues'] = self.fetch_issues(fork_repo, 'fork', state)
|
||||
if include_prs:
|
||||
results['fork_prs'] = self.fetch_pull_requests(fork_repo, 'fork', state)
|
||||
|
||||
# Log summary
|
||||
total = sum(len(items) for items in results.values())
|
||||
self.log(f"\n=� Summary: Fetched {total} total items")
|
||||
self.log(f" Target Issues: {len(results['target_issues'])}")
|
||||
self.log(f" Target PRs: {len(results['target_prs'])}")
|
||||
if fork_repo:
|
||||
self.log(f" Fork Issues: {len(results['fork_issues'])}")
|
||||
self.log(f" Fork PRs: {len(results['fork_prs'])}")
|
||||
|
||||
return results
|
||||
|
||||
def get_combined_items(self, workflow_items: Dict[str, List[WorkflowItem]],
|
||||
sort_by: str = 'updated') -> List[WorkflowItem]:
|
||||
"""
|
||||
Combine and sort all workflow items
|
||||
|
||||
Args:
|
||||
workflow_items: Dictionary from fetch_all_workflow_items()
|
||||
sort_by: 'updated', 'created', or 'number'
|
||||
|
||||
Returns:
|
||||
Sorted list of all workflow items
|
||||
"""
|
||||
all_items = []
|
||||
for items_list in workflow_items.values():
|
||||
all_items.extend(items_list)
|
||||
|
||||
# Sort items
|
||||
if sort_by == 'updated':
|
||||
all_items.sort(key=lambda x: x.updated_at, reverse=True)
|
||||
elif sort_by == 'created':
|
||||
all_items.sort(key=lambda x: x.created_at, reverse=True)
|
||||
elif sort_by == 'number':
|
||||
all_items.sort(key=lambda x: x.number, reverse=True)
|
||||
|
||||
return all_items
|
||||
|
||||
def filter_items(self, items: List[WorkflowItem], **filters) -> List[WorkflowItem]:
|
||||
"""
|
||||
Filter workflow items based on criteria
|
||||
|
||||
Args:
|
||||
items: List of WorkflowItem objects
|
||||
**filters: Filter criteria (state, item_type, repo_source, author, labels)
|
||||
|
||||
Returns:
|
||||
Filtered list of items
|
||||
"""
|
||||
filtered = items
|
||||
|
||||
if 'state' in filters and filters['state']:
|
||||
filtered = [item for item in filtered if item.state == filters['state']]
|
||||
|
||||
if 'item_type' in filters and filters['item_type']:
|
||||
filtered = [item for item in filtered if item.item_type == filters['item_type']]
|
||||
|
||||
if 'repo_source' in filters and filters['repo_source']:
|
||||
filtered = [item for item in filtered if item.repo_source == filters['repo_source']]
|
||||
|
||||
if 'author' in filters and filters['author']:
|
||||
filtered = [item for item in filtered if item.author == filters['author']]
|
||||
|
||||
if 'labels' in filters and filters['labels']:
|
||||
label_filter = filters['labels']
|
||||
if isinstance(label_filter, str):
|
||||
label_filter = [label_filter]
|
||||
filtered = [item for item in filtered
|
||||
if any(label in item.labels for label in label_filter)]
|
||||
|
||||
return filtered
|
||||
Reference in New Issue
Block a user