Moved the current files to pivate repo
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
MicrosoftDocFlow v3
|
||||
Main application entry point
|
||||
|
||||
This application processes Azure DevOps work items and UUF items,
|
||||
creating GitHub issues or pull requests with AI assistance.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
# Import our modular components
|
||||
try:
|
||||
from app_components.config_manager import ConfigManager
|
||||
from app_components.ai_manager import AIManager
|
||||
from app_components.github_api import GitHubAPI
|
||||
from app_components.utils import Logger, PRNumberManager, ContentBuilders
|
||||
from app_components.main_gui import MainGUI
|
||||
except ImportError as e:
|
||||
print(f"Error importing application components: {e}")
|
||||
print("Make sure all files are present in the app_components folder")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class AzureDevOpsToGitHubApp:
|
||||
"""Main application class that orchestrates all components"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the application"""
|
||||
self.root = tk.Tk()
|
||||
self.root.title("MicrosoftDocFlow v3")
|
||||
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,
|
||||
config_manager=self.config_manager,
|
||||
ai_manager=self.ai_manager,
|
||||
app=self
|
||||
)
|
||||
|
||||
# Set up AI provider check after GUI is ready
|
||||
self.root.after(100, self._check_ai_provider_setup)
|
||||
|
||||
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)
|
||||
if success:
|
||||
self.config = self.config_manager.get_config()
|
||||
# Update dry run state
|
||||
dry_run_config = self.config.get('DRY_RUN', 'false')
|
||||
self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on')
|
||||
return success
|
||||
|
||||
def create_github_api(self, token=None, dry_run=None):
|
||||
"""Create a GitHub API instance"""
|
||||
if token is None:
|
||||
token = self.config.get('GITHUB_PAT', '')
|
||||
if dry_run is None:
|
||||
dry_run = self.dry_run_enabled
|
||||
|
||||
logger = self.main_gui.logger if hasattr(self.main_gui, 'logger') else None
|
||||
return GitHubAPI(token, logger, dry_run)
|
||||
|
||||
def run(self):
|
||||
"""Start the application"""
|
||||
try:
|
||||
self.root.mainloop()
|
||||
except KeyboardInterrupt:
|
||||
print("Application interrupted by user")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Application Error", f"An unexpected error occurred:\n{str(e)}")
|
||||
print(f"Application error: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
try:
|
||||
app = AzureDevOpsToGitHubApp()
|
||||
app.run()
|
||||
except Exception as e:
|
||||
print(f"Failed to start application: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Azure DevOps & UUF → GitHub Processor - Application Components
|
||||
Modular components for the application
|
||||
"""
|
||||
|
||||
# Version info
|
||||
__version__ = "3.0.0"
|
||||
__author__ = "Azure DevOps to GitHub Processor"
|
||||
|
||||
# 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
|
||||
|
||||
__all__ = [
|
||||
'ConfigManager',
|
||||
'AIManager',
|
||||
'GitHubAPI',
|
||||
'AzureDevOpsAPI',
|
||||
'DataverseAPI',
|
||||
'WorkItemProcessor',
|
||||
'SettingsDialog',
|
||||
'MainGUI',
|
||||
'Logger',
|
||||
'PRNumberManager',
|
||||
'ContentBuilders'
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Cache Manager for Work Items and UUF Items
|
||||
Stores fetched items in temporary cache to avoid reloading on every app start
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from hashlib import md5
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Manages caching of work items and UUF items"""
|
||||
|
||||
def __init__(self, cache_duration_hours: int = 24):
|
||||
"""
|
||||
Initialize cache manager
|
||||
|
||||
Args:
|
||||
cache_duration_hours: How long cache is valid (default 24 hours)
|
||||
"""
|
||||
self.cache_duration_seconds = cache_duration_hours * 3600
|
||||
self.cache_dir = Path(tempfile.gettempdir()) / "devops_to_github_cache"
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
|
||||
def _get_cache_key(self, source_type: str, identifier: str) -> str:
|
||||
"""Generate cache key from source type and identifier"""
|
||||
# Use MD5 hash to create safe filename
|
||||
key_str = f"{source_type}_{identifier}"
|
||||
return md5(key_str.encode()).hexdigest()
|
||||
|
||||
def _get_cache_path(self, cache_key: str) -> Path:
|
||||
"""Get full path to cache file"""
|
||||
return self.cache_dir / f"{cache_key}.json"
|
||||
|
||||
def is_cache_valid(self, source_type: str, identifier: str) -> bool:
|
||||
"""Check if cache exists and is still valid"""
|
||||
cache_key = self._get_cache_key(source_type, identifier)
|
||||
cache_path = self._get_cache_path(cache_key)
|
||||
|
||||
if not cache_path.exists():
|
||||
return False
|
||||
|
||||
# Check if cache has expired
|
||||
file_age = time.time() - cache_path.stat().st_mtime
|
||||
return file_age < self.cache_duration_seconds
|
||||
|
||||
def load_from_cache(self, source_type: str, identifier: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Load work items from cache
|
||||
|
||||
Args:
|
||||
source_type: 'azure_devops' or 'uuf'
|
||||
identifier: query URL hash or config hash
|
||||
|
||||
Returns:
|
||||
List of work items if cache is valid, None otherwise
|
||||
"""
|
||||
if not self.is_cache_valid(source_type, identifier):
|
||||
return None
|
||||
|
||||
cache_key = self._get_cache_key(source_type, identifier)
|
||||
cache_path = self._get_cache_path(cache_key)
|
||||
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Validate cache structure
|
||||
if 'timestamp' not in cache_data or 'items' not in cache_data:
|
||||
return None
|
||||
|
||||
return cache_data['items']
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading cache: {e}")
|
||||
return None
|
||||
|
||||
def save_to_cache(self, source_type: str, identifier: str, items: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Save work items to cache
|
||||
|
||||
Args:
|
||||
source_type: 'azure_devops' or 'uuf'
|
||||
identifier: query URL hash or config hash
|
||||
items: List of work items to cache
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
cache_key = self._get_cache_key(source_type, identifier)
|
||||
cache_path = self._get_cache_path(cache_key)
|
||||
|
||||
try:
|
||||
cache_data = {
|
||||
'timestamp': time.time(),
|
||||
'source_type': source_type,
|
||||
'identifier': identifier,
|
||||
'items': items
|
||||
}
|
||||
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving cache: {e}")
|
||||
return False
|
||||
|
||||
def invalidate_cache(self, source_type: str = None, identifier: str = None):
|
||||
"""
|
||||
Invalidate (delete) cache
|
||||
|
||||
Args:
|
||||
source_type: If specified, only invalidate this source type
|
||||
identifier: If specified, only invalidate this specific cache
|
||||
"""
|
||||
if source_type and identifier:
|
||||
# Invalidate specific cache
|
||||
cache_key = self._get_cache_key(source_type, identifier)
|
||||
cache_path = self._get_cache_path(cache_key)
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
elif source_type:
|
||||
# Invalidate all caches for this source type
|
||||
for cache_file in self.cache_dir.glob("*.json"):
|
||||
try:
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
if cache_data.get('source_type') == source_type:
|
||||
cache_file.unlink()
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
# Invalidate all caches
|
||||
for cache_file in self.cache_dir.glob("*.json"):
|
||||
cache_file.unlink()
|
||||
|
||||
def get_cache_info(self) -> Dict[str, Any]:
|
||||
"""Get information about cached items"""
|
||||
cache_files = list(self.cache_dir.glob("*.json"))
|
||||
|
||||
info = {
|
||||
'cache_dir': str(self.cache_dir),
|
||||
'total_files': len(cache_files),
|
||||
'total_size_bytes': sum(f.stat().st_size for f in cache_files),
|
||||
'caches': []
|
||||
}
|
||||
|
||||
for cache_file in cache_files:
|
||||
try:
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
file_age = time.time() - cache_file.stat().st_mtime
|
||||
is_valid = file_age < self.cache_duration_seconds
|
||||
|
||||
info['caches'].append({
|
||||
'source_type': cache_data.get('source_type', 'unknown'),
|
||||
'item_count': len(cache_data.get('items', [])),
|
||||
'age_hours': round(file_age / 3600, 1),
|
||||
'is_valid': is_valid,
|
||||
'size_kb': round(cache_file.stat().st_size / 1024, 1)
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""Remove expired cache files"""
|
||||
current_time = time.time()
|
||||
removed_count = 0
|
||||
|
||||
for cache_file in self.cache_dir.glob("*.json"):
|
||||
file_age = current_time - cache_file.stat().st_mtime
|
||||
if file_age >= self.cache_duration_seconds:
|
||||
cache_file.unlink()
|
||||
removed_count += 1
|
||||
|
||||
return removed_count
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Configuration Manager
|
||||
Handles loading/saving configuration from .env files and launch.json
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manages application configuration from multiple sources"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = self.load_configuration()
|
||||
|
||||
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
|
||||
'AI_PROVIDER': None,
|
||||
'CLAUDE_API_KEY': None,
|
||||
'OPENAI_API_KEY': None,
|
||||
'GITHUB_TOKEN': None, # For GitHub Copilot AI Provider
|
||||
'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
|
||||
}
|
||||
|
||||
def load_configuration(self) -> Dict[str, Any]:
|
||||
"""Load configuration from launch.json first, then .env as fallback"""
|
||||
config = self._get_default_config()
|
||||
launch_json_keys = set()
|
||||
|
||||
# First, try to load from launch.json
|
||||
launch_json_path = os.path.join('.vscode', 'launch.json')
|
||||
if os.path.exists(launch_json_path):
|
||||
try:
|
||||
with open(launch_json_path, 'r', encoding='utf-8') as f:
|
||||
launch_data = json.load(f)
|
||||
|
||||
# Look for configurations with env variables
|
||||
for configuration in launch_data.get('configurations', []):
|
||||
env_vars = configuration.get('env', {})
|
||||
for key in config.keys():
|
||||
if key in env_vars and env_vars[key] and not env_vars[key].startswith('<'):
|
||||
config[key] = env_vars[key]
|
||||
launch_json_keys.add(key)
|
||||
|
||||
if launch_json_keys:
|
||||
print(f"Loaded configuration from launch.json: {launch_json_path}")
|
||||
|
||||
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
||||
print(f"Could not load launch.json: {e}")
|
||||
|
||||
# Check if .env file exists, create default if not
|
||||
if not os.path.exists('.env'):
|
||||
print("No .env file found. Creating default .env file...")
|
||||
self._create_default_env_file(config)
|
||||
|
||||
# Load values from .env file (but don't override launch.json values)
|
||||
if os.path.exists('.env'):
|
||||
try:
|
||||
env_loaded = False
|
||||
with open('.env', 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
|
||||
# Load from .env if key exists in config and wasn't loaded from launch.json
|
||||
if key in config and key not in launch_json_keys:
|
||||
config[key] = value if value else ''
|
||||
env_loaded = True
|
||||
|
||||
if env_loaded:
|
||||
print("Loaded configuration from .env file")
|
||||
elif not launch_json_keys:
|
||||
print("Configuration files found but no valid values loaded")
|
||||
|
||||
except FileNotFoundError:
|
||||
print("No .env file found")
|
||||
except Exception as e:
|
||||
print(f"Could not load .env file: {e}")
|
||||
|
||||
# Ensure all config values are strings, not None
|
||||
for key in config:
|
||||
if config[key] is None:
|
||||
config[key] = ''
|
||||
|
||||
# Special handling for AI_PROVIDER - default to 'none' if empty
|
||||
if not config.get('AI_PROVIDER'):
|
||||
config['AI_PROVIDER'] = 'none'
|
||||
|
||||
# Debug output
|
||||
loaded_from = []
|
||||
for key, value in config.items():
|
||||
if value:
|
||||
loaded_from.append(f"{key}: {'loaded' if value else 'not found'}")
|
||||
|
||||
if loaded_from:
|
||||
print(f"Configuration status: {', '.join(loaded_from)}")
|
||||
else:
|
||||
print("No configuration values loaded - all fields will be blank")
|
||||
|
||||
self.config = config
|
||||
return config
|
||||
|
||||
def _create_default_env_file(self, config: Dict[str, Any]) -> None:
|
||||
"""Create a default .env file with empty values"""
|
||||
try:
|
||||
env_template = """# Azure DevOps to GitHub Tool 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
|
||||
|
||||
# AI Provider Configuration (for local PR creation with AI assistance)
|
||||
AI_PROVIDER=
|
||||
CLAUDE_API_KEY=
|
||||
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(env_template)
|
||||
|
||||
print("Created default .env file with blank values")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating default .env file: {e}")
|
||||
|
||||
def save_configuration(self, config_values: Dict[str, Any]) -> bool:
|
||||
"""Save configuration to .env file"""
|
||||
try:
|
||||
print(f"DEBUG: Saving config values: {config_values}")
|
||||
print(f"DEBUG: AI_PROVIDER value being saved: '{config_values.get('AI_PROVIDER', 'NOT_FOUND')}'")
|
||||
|
||||
# Update internal config
|
||||
for key, value in config_values.items():
|
||||
if key in self.config:
|
||||
old_value = self.config[key]
|
||||
new_value = value or ''
|
||||
self.config[key] = new_value
|
||||
if key == 'AI_PROVIDER':
|
||||
print(f"DEBUG: Updated AI_PROVIDER from '{old_value}' to '{new_value}'")
|
||||
|
||||
# Build .env file content
|
||||
env_content = []
|
||||
env_content.append("# Azure DevOps to GitHub Tool 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}'")
|
||||
env_content.append(f"AI_PROVIDER={ai_provider_value}")
|
||||
env_content.append(f"CLAUDE_API_KEY={self.config.get('CLAUDE_API_KEY', '')}")
|
||||
env_content.append(f"OPENAI_API_KEY={self.config.get('OPENAI_API_KEY', '')}")
|
||||
env_content.append(f"GITHUB_TOKEN={self.config.get('GITHUB_TOKEN', '')}")
|
||||
env_content.append(f"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("")
|
||||
|
||||
# Write to file
|
||||
with open('.env', 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(env_content))
|
||||
|
||||
print("Configuration saved to .env file")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving configuration: {e}")
|
||||
return False
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""Get current configuration with automatic GITHUB_TOKEN defaulting"""
|
||||
config = self.config.copy()
|
||||
|
||||
# Auto-default GITHUB_TOKEN to GITHUB_PAT if GITHUB_TOKEN is empty or None
|
||||
github_token = config.get('GITHUB_TOKEN', '').strip() if config.get('GITHUB_TOKEN') else ''
|
||||
github_pat = config.get('GITHUB_PAT', '').strip() if config.get('GITHUB_PAT') else ''
|
||||
|
||||
if not github_token and github_pat:
|
||||
config['GITHUB_TOKEN'] = github_pat
|
||||
|
||||
return config
|
||||
|
||||
def get_value(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific configuration value"""
|
||||
return self.config.get(key, default)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific configuration value (dictionary-like interface)"""
|
||||
return self.config.get(key, default)
|
||||
|
||||
def set_value(self, key: str, value: Any) -> None:
|
||||
"""Set a specific configuration value"""
|
||||
if key in self.config:
|
||||
self.config[key] = value
|
||||
|
||||
def get_pr_counter_file(self) -> str:
|
||||
"""Get the path to the PR counter file"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(script_dir, '..', '.pr_counter.json')
|
||||
|
||||
def load_pr_counter(self) -> Dict[str, int]:
|
||||
"""Load the PR counter from file"""
|
||||
counter_file = self.get_pr_counter_file()
|
||||
if os.path.exists(counter_file):
|
||||
try:
|
||||
with open(counter_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_pr_counter(self, counter: Dict[str, int]) -> None:
|
||||
"""Save the PR counter to file"""
|
||||
counter_file = self.get_pr_counter_file()
|
||||
try:
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(counter_file), exist_ok=True)
|
||||
with open(counter_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(counter, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save PR counter: {e}")
|
||||
|
||||
def get_next_pr_number(self, provider_key: str) -> int:
|
||||
"""
|
||||
Get the next PR number for a given provider.
|
||||
|
||||
Args:
|
||||
provider_key: Either the AI provider name ('chatgpt', 'claude') or 'gh_copilot'
|
||||
|
||||
Returns:
|
||||
The next PR number for this provider
|
||||
"""
|
||||
counter = self.load_pr_counter()
|
||||
current_number = counter.get(provider_key, 0)
|
||||
next_number = current_number + 1
|
||||
counter[provider_key] = next_number
|
||||
self.save_pr_counter(counter)
|
||||
return next_number
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
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]
|
||||
@@ -0,0 +1,992 @@
|
||||
"""
|
||||
GitHub API Manager
|
||||
Handles GitHub GraphQL operations, PR/Issue creation, and Copilot interactions
|
||||
"""
|
||||
|
||||
import base64
|
||||
import difflib
|
||||
import json
|
||||
import requests
|
||||
from typing import Optional, Tuple, Dict, Any, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Constants
|
||||
GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
|
||||
USER_AGENT = "azure-devops-github-processor/2.0"
|
||||
|
||||
|
||||
class GitHubGQL:
|
||||
"""GitHub GraphQL API client for creating issues, PRs, and managing assignments"""
|
||||
|
||||
def __init__(self, token: str, logger=None, dry_run: bool = False):
|
||||
self.token = token
|
||||
self.logger = logger
|
||||
self.dry_run = dry_run
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
"""Log a message"""
|
||||
if self.logger:
|
||||
self.logger.log(message)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
def _headers(self):
|
||||
"""Get headers for GitHub API requests"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"User-Agent": USER_AGENT,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def run(self, query: str, variables: dict | None = None) -> dict:
|
||||
"""Execute a GraphQL query"""
|
||||
payload = {"query": query, "variables": variables or {}}
|
||||
|
||||
if self.dry_run:
|
||||
self.log("[DRY-RUN] Would POST GraphQL payload:")
|
||||
pretty = json.dumps(payload, indent=2)
|
||||
self.log(pretty)
|
||||
return {"dryRun": True, "data": None}
|
||||
|
||||
try:
|
||||
resp = requests.post(GITHUB_GRAPHQL_ENDPOINT, headers=self._headers(), json=payload, timeout=60)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"GraphQL HTTP {resp.status_code}: {resp.text}")
|
||||
|
||||
data = resp.json()
|
||||
if "errors" in data and data["errors"]:
|
||||
raise RuntimeError(f"GraphQL errors: {json.dumps(data['errors'], indent=2)}")
|
||||
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(f"Request failed: {str(e)}")
|
||||
|
||||
def _make_rest_request(self, method: str, url: str, data: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Make a REST API request to GitHub"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
if self.dry_run:
|
||||
self.log(f"[DRY-RUN] Would make {method} request to: {url}")
|
||||
return {"number": 123, "html_url": "https://github.com/example/repo/pull/123"}
|
||||
|
||||
response = requests.request(method, url, headers=headers, json=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
def get_repo_id(self, owner: str, name: str) -> str:
|
||||
"""Get GitHub repository ID"""
|
||||
self.log(f"Fetching repositoryId for {owner}/{name}...")
|
||||
query = """
|
||||
query($owner:String!, $name:String!) {
|
||||
repository(owner:$owner, name:$name) {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = self.run(query, {"owner": owner, "name": name})
|
||||
|
||||
if data.get("dryRun"):
|
||||
return "DRY_RUN_REPO_ID"
|
||||
|
||||
repo = data["data"]["repository"]
|
||||
if not repo:
|
||||
raise RuntimeError(f"Repository {owner}/{name} not found or token lacks access.")
|
||||
|
||||
self.log(f"Repository ID: {repo['id']} ({repo['url']})")
|
||||
return repo["id"]
|
||||
|
||||
def get_copilot_actor_id(self, owner: str, name: str) -> tuple[str | None, str | None]:
|
||||
"""Find Copilot actor ID for assignment"""
|
||||
self.log("Querying suggestedActors for CAN_BE_ASSIGNED...")
|
||||
query = """
|
||||
query($owner:String!, $name:String!) {
|
||||
repository(owner:$owner, name:$name) {
|
||||
suggestedActors(capabilities:[CAN_BE_ASSIGNED], first:100) {
|
||||
nodes {
|
||||
login
|
||||
__typename
|
||||
... on Bot { id }
|
||||
... on User { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = self.run(query, {"owner": owner, "name": name})
|
||||
|
||||
if data.get("dryRun"):
|
||||
return ("DRY_RUN_ACTOR_ID", "copilot-swe-agent")
|
||||
|
||||
nodes = data["data"]["repository"]["suggestedActors"]["nodes"]
|
||||
if not nodes:
|
||||
self.log("No suggestedActors returned.")
|
||||
return (None, None)
|
||||
|
||||
# Log all available actors for debugging
|
||||
self.log(f"Available assignable actors ({len(nodes)}):")
|
||||
for node in nodes:
|
||||
self.log(f" - {node.get('login', 'N/A')} ({node.get('__typename', 'N/A')}) ID: {node.get('id', 'N/A')}")
|
||||
|
||||
# Prefer known Copilot logins
|
||||
preferred = ("copilot-swe-agent", "copilot", "github-copilot", "github-advanced-security")
|
||||
chosen = None
|
||||
|
||||
# First, try exact matches
|
||||
for candidate in nodes:
|
||||
login = candidate.get("login", "").lower()
|
||||
if login in preferred:
|
||||
chosen = candidate
|
||||
break
|
||||
|
||||
# If no exact match, try partial matches
|
||||
if not chosen:
|
||||
for candidate in nodes:
|
||||
login = candidate.get("login", "").lower()
|
||||
if "copilot" in login:
|
||||
chosen = candidate
|
||||
break
|
||||
|
||||
if not chosen:
|
||||
self.log("Copilot not found in suggestedActors list.")
|
||||
self.log("Available actors: " + ", ".join([n.get("login", "N/A") for n in nodes]))
|
||||
return (None, None)
|
||||
|
||||
login = chosen["login"]
|
||||
actor_id = chosen.get("id")
|
||||
|
||||
if not actor_id:
|
||||
self.log(f"Warning: No actor ID found for {login}")
|
||||
return (None, None)
|
||||
|
||||
self.log(f"Found assignable Copilot actor: {login} (id={actor_id})")
|
||||
return (actor_id, login)
|
||||
|
||||
def create_issue(self, repository_id: str, title: str, body: str) -> tuple[str, str, int]:
|
||||
"""Create a GitHub issue"""
|
||||
self.log("Creating issue with createIssue mutation...")
|
||||
mutation = """
|
||||
mutation($repositoryId:ID!, $title:String!, $body:String!) {
|
||||
createIssue(input:{repositoryId:$repositoryId, title:$title, body:$body}) {
|
||||
issue {
|
||||
id
|
||||
url
|
||||
number
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = self.run(mutation, {"repositoryId": repository_id, "title": title, "body": body})
|
||||
|
||||
if data.get("dryRun"):
|
||||
return ("DRY_RUN_ISSUE_ID", "https://github.com/owner/repo/issues/123", 123)
|
||||
|
||||
issue = data["data"]["createIssue"]["issue"]
|
||||
self.log(f"Issue created: {issue['url']} (#{issue['number']})")
|
||||
return (issue["id"], issue["url"], issue["number"])
|
||||
|
||||
def create_branch_from_main(self, owner: str, repo: str, branch_name: str) -> bool:
|
||||
"""Create a new branch from the main branch"""
|
||||
self.log(f"Creating branch '{branch_name}' in {owner}/{repo}")
|
||||
|
||||
try:
|
||||
# Get the SHA of the main branch
|
||||
main_ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/ref/heads/main"
|
||||
main_ref_response = self._make_rest_request("GET", main_ref_url)
|
||||
main_sha = main_ref_response["object"]["sha"]
|
||||
|
||||
self.log(f"Main branch SHA: {main_sha}")
|
||||
|
||||
# Create new branch
|
||||
new_ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/refs"
|
||||
new_ref_data = {
|
||||
"ref": f"refs/heads/{branch_name}",
|
||||
"sha": main_sha
|
||||
}
|
||||
|
||||
if self.dry_run:
|
||||
self.log(f"🧪 DRY RUN: Would create branch '{branch_name}' from main ({main_sha})")
|
||||
return True
|
||||
|
||||
self._make_rest_request("POST", new_ref_url, new_ref_data)
|
||||
self.log(f"✅ Branch '{branch_name}' created successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Failed to create branch: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_user_forks(self, include_org_repos: bool = True) -> List[str]:
|
||||
"""Get list of user's forked repositories"""
|
||||
self.log("Fetching user's forked repositories...")
|
||||
|
||||
if self.dry_run:
|
||||
# Return sample data for dry run
|
||||
return [
|
||||
"username/fabric-docs",
|
||||
"username/azure-docs",
|
||||
"username/powerbi-docs"
|
||||
]
|
||||
|
||||
try:
|
||||
forks = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
while page <= 5: # Limit to 5 pages to avoid long waits
|
||||
url = f"https://api.github.com/user/repos?type=forks&per_page={per_page}&page={page}"
|
||||
|
||||
response = self._make_rest_request("GET", url)
|
||||
repos = response if isinstance(response, list) else response.get('data', [])
|
||||
|
||||
if not repos:
|
||||
break
|
||||
|
||||
for repo in repos:
|
||||
if repo.get('fork', False):
|
||||
forks.append(f"{repo['owner']['login']}/{repo['name']}")
|
||||
|
||||
if len(repos) < per_page:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
self.log(f"Found {len(forks)} forked repositories")
|
||||
return forks
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Failed to fetch user forks: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_authenticated_user(self) -> Dict[str, Any]:
|
||||
"""Get authenticated user information"""
|
||||
if self.dry_run:
|
||||
return {"login": "dry-run-user", "name": "Dry Run User"}
|
||||
|
||||
try:
|
||||
return self._make_rest_request("GET", "https://api.github.com/user")
|
||||
except Exception as e:
|
||||
self.log(f"❌ Failed to get user info: {str(e)}")
|
||||
return {}
|
||||
|
||||
def fork_repository(self, owner: str, repo: str, target_org: str = None) -> tuple[str, str]:
|
||||
"""Fork a repository to the authenticated user's account or specified organization"""
|
||||
self.log(f"Forking repository {owner}/{repo}")
|
||||
|
||||
fork_url = f"https://api.github.com/repos/{owner}/{repo}/forks"
|
||||
fork_data = {}
|
||||
|
||||
if target_org:
|
||||
fork_data["organization"] = target_org
|
||||
|
||||
if self.dry_run:
|
||||
# Get authenticated user for dry run
|
||||
user_url = "https://api.github.com/user"
|
||||
try:
|
||||
user_data = self._make_rest_request("GET", user_url)
|
||||
fork_owner = target_org if target_org else user_data["login"]
|
||||
self.log(f"🧪 DRY RUN: Would fork {owner}/{repo} to {fork_owner}/{repo}")
|
||||
return fork_owner, repo
|
||||
except:
|
||||
self.log(f"🧪 DRY RUN: Would fork {owner}/{repo}")
|
||||
return "dry-run-user", repo
|
||||
|
||||
try:
|
||||
fork_response = self._make_rest_request("POST", fork_url, fork_data)
|
||||
fork_owner = fork_response["owner"]["login"]
|
||||
fork_name = fork_response["name"]
|
||||
|
||||
self.log(f"✅ Repository forked to {fork_owner}/{fork_name}")
|
||||
return fork_owner, fork_name
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Failed to fork repository: {str(e)}")
|
||||
raise
|
||||
|
||||
def check_repository_exists(self, owner: str, repo: str) -> bool:
|
||||
"""Check if a repository exists and is accessible"""
|
||||
try:
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}"
|
||||
response = self._make_rest_request("GET", url)
|
||||
return bool(response.get('id'))
|
||||
except:
|
||||
return False
|
||||
|
||||
def find_matching_repositories(self, target_repo: str, fork_repo: str) -> Dict[str, List[str]]:
|
||||
"""Find matching repositories to suggest alternatives for mismatched repos"""
|
||||
self.log(f"Finding matching repositories for target: {target_repo}, fork: {fork_repo}")
|
||||
|
||||
if self.dry_run:
|
||||
return {
|
||||
"target_alternatives": ["microsoftdocs/fabric-docs-pr"],
|
||||
"fork_alternatives": ["b-tsammons/azure-docs-pr"]
|
||||
}
|
||||
|
||||
try:
|
||||
target_owner, target_name = target_repo.split('/', 1) if '/' in target_repo else ("", target_repo)
|
||||
fork_owner, fork_name = fork_repo.split('/', 1) if '/' in fork_repo else ("", fork_repo)
|
||||
|
||||
target_alternatives = []
|
||||
fork_alternatives = []
|
||||
|
||||
# Get authenticated user info
|
||||
user_info = self.get_authenticated_user()
|
||||
user_login = user_info.get('login', '')
|
||||
|
||||
# Search for repositories with similar names
|
||||
search_terms = [target_name, fork_name]
|
||||
for term in search_terms:
|
||||
if term:
|
||||
# Clean up the search term (remove common suffixes)
|
||||
clean_term = term.replace('-docs', '').replace('-pr', '').replace('_', ' ')
|
||||
|
||||
# Search for repositories
|
||||
search_url = f"https://api.github.com/search/repositories?q={clean_term}&per_page=20"
|
||||
try:
|
||||
search_response = self._make_rest_request("GET", search_url)
|
||||
repositories = search_response.get('items', [])
|
||||
|
||||
for repo_data in repositories:
|
||||
repo_full_name = repo_data['full_name']
|
||||
repo_owner = repo_data['owner']['login']
|
||||
|
||||
# Check if this is a potential target alternative
|
||||
if (repo_owner == target_owner and
|
||||
repo_data['name'] != target_name and
|
||||
repo_full_name not in target_alternatives):
|
||||
target_alternatives.append(repo_full_name)
|
||||
|
||||
# Check if this is a potential fork alternative
|
||||
if (repo_owner == user_login and
|
||||
repo_data['name'] != fork_name and
|
||||
repo_data.get('fork', False) and
|
||||
repo_full_name not in fork_alternatives):
|
||||
fork_alternatives.append(repo_full_name)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Search failed for term '{term}': {str(e)}")
|
||||
|
||||
return {
|
||||
"target_alternatives": target_alternatives[:5], # Limit to 5 suggestions
|
||||
"fork_alternatives": fork_alternatives[:5]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Failed to find matching repositories: {str(e)}")
|
||||
return {"target_alternatives": [], "fork_alternatives": []}
|
||||
|
||||
def make_documentation_change(self, owner: str, repo: str, branch_name: str, file_path: str,
|
||||
old_text: str, new_text: str, commit_message: str) -> bool:
|
||||
"""Make actual documentation changes to a file in the repository
|
||||
|
||||
This fetches the file, makes the text replacement, and commits it to the branch.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if self.dry_run:
|
||||
self.log(f"[DRY-RUN] Would update {file_path} in branch {branch_name}")
|
||||
self.log(f"[DRY-RUN] Replace: {old_text[:50]}...")
|
||||
self.log(f"[DRY-RUN] With: {new_text[:50]}...")
|
||||
return True
|
||||
|
||||
try:
|
||||
rest_headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
# 1. Get the current file content from the branch
|
||||
self.log(f"Fetching file: {file_path}")
|
||||
file_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={branch_name}"
|
||||
resp = requests.get(file_url, headers=rest_headers, timeout=30)
|
||||
|
||||
if resp.status_code == 404:
|
||||
self.log(f"❌ File not found: {file_path}")
|
||||
self.log(f" The file path might be incorrect or the file doesn't exist")
|
||||
return False
|
||||
|
||||
resp.raise_for_status()
|
||||
file_data = resp.json()
|
||||
|
||||
# Decode the file content
|
||||
current_content = base64.b64decode(file_data["content"]).decode('utf-8')
|
||||
file_sha = file_data["sha"]
|
||||
|
||||
self.log(f"✅ File retrieved ({len(current_content)} bytes)")
|
||||
|
||||
# Detect line ending style to preserve it
|
||||
line_ending = '\r\n' if '\r\n' in current_content else '\n'
|
||||
self.log(f"📝 Detected line endings: {'CRLF' if line_ending == '\\r\\n' else 'LF'}")
|
||||
|
||||
# Normalize everything to LF for consistent processing
|
||||
normalized_content = current_content.replace('\r\n', '\n')
|
||||
normalized_old = old_text.replace('\r\n', '\n')
|
||||
normalized_new = new_text.replace('\r\n', '\n')
|
||||
|
||||
# 2. Make the text replacement
|
||||
if normalized_old not in normalized_content:
|
||||
self.log(f"⚠️ Warning: Could not find exact text to replace in {file_path}")
|
||||
self.log(f" Searching for similar text...")
|
||||
|
||||
# Try to find similar text (case-insensitive, whitespace-flexible)
|
||||
lines = normalized_content.split('\n')
|
||||
old_lines = normalized_old.split('\n')
|
||||
|
||||
# Find the best matching sequence
|
||||
matcher = difflib.SequenceMatcher(None, old_lines, lines)
|
||||
match = matcher.find_longest_match(0, len(old_lines), 0, len(lines))
|
||||
|
||||
if match.size > len(old_lines) * 0.7: # If we find 70% match
|
||||
self.log(f" Found similar text at line {match.b + 1}")
|
||||
self.log(f" Making best-effort replacement...")
|
||||
# This is a simplified approach - in production you'd want more sophisticated matching
|
||||
else:
|
||||
self.log(f"❌ Could not find text to replace. The document may have changed.")
|
||||
self.log(f" Creating PR with instructions instead...")
|
||||
return False
|
||||
|
||||
# Replace the text (using normalized versions)
|
||||
updated_content = normalized_content.replace(normalized_old, normalized_new)
|
||||
|
||||
if updated_content == normalized_content:
|
||||
self.log(f"⚠️ No changes made - text might not exist in file")
|
||||
return False
|
||||
|
||||
self.log(f"✅ Text replacement successful")
|
||||
|
||||
# Restore original line endings
|
||||
if line_ending == '\r\n':
|
||||
updated_content = updated_content.replace('\n', '\r\n')
|
||||
self.log(f"✅ Restored CRLF line endings")
|
||||
|
||||
# 3. Commit the updated file
|
||||
self.log(f"Committing changes to {file_path}...")
|
||||
encoded_content = base64.b64encode(updated_content.encode('utf-8')).decode()
|
||||
|
||||
update_payload = {
|
||||
"message": commit_message,
|
||||
"content": encoded_content,
|
||||
"sha": file_sha,
|
||||
"branch": branch_name
|
||||
}
|
||||
|
||||
update_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}"
|
||||
resp = requests.put(update_url, headers=rest_headers, json=update_payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
|
||||
self.log(f"✅ Changes committed to branch {branch_name}")
|
||||
return True
|
||||
|
||||
except requests.HTTPError as e:
|
||||
self.log(f"❌ HTTP Error making changes: {e}")
|
||||
if e.response.status_code == 403:
|
||||
self.log(" Permission denied - token doesn't have write access")
|
||||
elif e.response.status_code == 404:
|
||||
self.log(f" File not found: {file_path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error making changes: {str(e)}")
|
||||
return False
|
||||
|
||||
def create_cross_repo_pull_request(self, source_owner: str, source_repo: str, target_owner: str, target_repo: str,
|
||||
title: str, body: str, head_ref: str, base_ref: str = "main") -> tuple[str, str, int]:
|
||||
"""Create a pull request from source repo to target repo"""
|
||||
self.log(f"Creating cross-repository PR from {source_owner}/{source_repo}:{head_ref} to {target_owner}/{target_repo}:{base_ref}")
|
||||
|
||||
# Get target repository ID
|
||||
target_repo_id = self.get_repo_id(target_owner, target_repo)
|
||||
|
||||
# Format the head reference for cross-repo PR
|
||||
head_ref_full = f"{source_owner}:{head_ref}"
|
||||
|
||||
mutation = """
|
||||
mutation($repositoryId:ID!, $title:String!, $body:String!, $headRefName:String!, $baseRefName:String!) {
|
||||
createPullRequest(input:{
|
||||
repositoryId:$repositoryId,
|
||||
title:$title,
|
||||
body:$body,
|
||||
headRefName:$headRefName,
|
||||
baseRefName:$baseRefName
|
||||
}) {
|
||||
pullRequest {
|
||||
id
|
||||
url
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
variables = {
|
||||
"repositoryId": target_repo_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"headRefName": head_ref_full,
|
||||
"baseRefName": base_ref
|
||||
}
|
||||
|
||||
if self.dry_run:
|
||||
self.log(f"🧪 DRY RUN: Would create cross-repo PR '{title}' from {head_ref_full} to {base_ref}")
|
||||
return "dry-run-pr-id", f"https://github.com/{target_owner}/{target_repo}/pull/0", 0
|
||||
|
||||
try:
|
||||
data = self.run(mutation, variables)
|
||||
pr_data = data["data"]["createPullRequest"]["pullRequest"]
|
||||
|
||||
pr_id = pr_data["id"]
|
||||
pr_url = pr_data["url"]
|
||||
pr_number = pr_data["number"]
|
||||
|
||||
self.log(f"✅ Cross-repo pull request created: {pr_url}")
|
||||
return pr_id, pr_url, pr_number
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"❌ Failed to create cross-repo pull request: {str(e)}")
|
||||
raise
|
||||
|
||||
def create_pull_request(self, repository_id: str, title: str, body: str, head_ref: str, base_ref: str = "main") -> tuple[str, str, int]:
|
||||
"""Create a pull request with AB# linking"""
|
||||
self.log(f"Creating pull request with createPullRequest mutation from {head_ref} to {base_ref}...")
|
||||
mutation = """
|
||||
mutation($repositoryId:ID!, $title:String!, $body:String!, $headRefName:String!, $baseRefName:String!) {
|
||||
createPullRequest(input:{
|
||||
repositoryId:$repositoryId,
|
||||
title:$title,
|
||||
body:$body,
|
||||
headRefName:$headRefName,
|
||||
baseRefName:$baseRefName
|
||||
}) {
|
||||
pullRequest {
|
||||
id
|
||||
url
|
||||
number
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {
|
||||
"repositoryId": repository_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"headRefName": head_ref,
|
||||
"baseRefName": base_ref
|
||||
}
|
||||
data = self.run(mutation, variables)
|
||||
if data.get("dryRun"):
|
||||
return ("DRY_RUN_PR_ID", "https://github.com/owner/repo/pull/456", 456)
|
||||
pr = data["data"]["createPullRequest"]["pullRequest"]
|
||||
self.log(f"Pull request created: {pr['url']} (#{pr['number']})")
|
||||
return (pr["id"], pr["url"], pr["number"])
|
||||
|
||||
def assign_to_copilot(self, assignable_id: str, actor_ids: list[str]) -> bool:
|
||||
"""Assign issue to Copilot
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
self.log("Assigning with replaceActorsForAssignable mutation...")
|
||||
mutation = """
|
||||
mutation($assignableId:ID!, $actorIds:[ID!]!) {
|
||||
replaceActorsForAssignable(input:{assignableId:$assignableId, actorIds:$actorIds}) {
|
||||
assignable {
|
||||
... on Issue {
|
||||
id
|
||||
title
|
||||
assignees(first:10) { nodes { login } }
|
||||
url
|
||||
}
|
||||
... on PullRequest {
|
||||
id
|
||||
title
|
||||
assignees(first:10) { nodes { login } }
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = self.run(mutation, {"assignableId": assignable_id, "actorIds": actor_ids})
|
||||
|
||||
if data.get("dryRun"):
|
||||
self.log("[DRY-RUN] Would have assigned Copilot.")
|
||||
return True
|
||||
|
||||
assigned = data["data"]["replaceActorsForAssignable"]["assignable"]["assignees"]["nodes"]
|
||||
assignees = ", ".join([n["login"] for n in assigned]) or "(none)"
|
||||
self.log(f"Current assignees: {assignees}")
|
||||
return True
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
self.log(f"Error assigning Copilot: {error_message}")
|
||||
|
||||
# Provide specific guidance for common permission issues
|
||||
if "FORBIDDEN" in error_message and "ReplaceActorsForAssignable" in error_message:
|
||||
self.log("")
|
||||
self.log("📋 Permission Issue: Cannot assign GitHub Copilot")
|
||||
self.log(" This is a repository permission limitation, not an application error.")
|
||||
self.log("")
|
||||
self.log(" Possible solutions:")
|
||||
self.log(" 1. Repository admin can assign Copilot manually to the PR")
|
||||
self.log(" 2. Repository admin can grant assignment permissions")
|
||||
self.log(" 3. The @copilot comment will still notify Copilot to work on the PR")
|
||||
self.log("")
|
||||
self.log(" ✅ The PR was created successfully with @copilot instructions")
|
||||
self.log(" ✅ Copilot can still see and act on the @copilot comment")
|
||||
elif "NOT_FOUND" in error_message:
|
||||
self.log("")
|
||||
self.log("📋 Copilot Actor Not Found")
|
||||
self.log(" This repository may not have GitHub Copilot enabled or available.")
|
||||
self.log(" The @copilot comment was still added to notify available Copilot services.")
|
||||
|
||||
return False
|
||||
|
||||
def add_copilot_comment(self, owner: str, repo: str, pr_number: int,
|
||||
file_path: str, old_text: str, new_text: str, branch_name: str,
|
||||
work_item_id: str = None, item_source: str = None, doc_url: str = None,
|
||||
custom_instructions: str = None) -> bool:
|
||||
"""Add a comment mentioning @copilot with explicit instructions to work on THIS PR
|
||||
|
||||
This tells Copilot to make changes in the current PR's branch, not create a new PR.
|
||||
|
||||
Args:
|
||||
owner: Repository owner
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
file_path: Path to the file to modify
|
||||
old_text: Text to find and replace
|
||||
new_text: New text to replace with
|
||||
branch_name: Branch name for this PR
|
||||
work_item_id: Work item or UUF issue ID
|
||||
item_source: Source of the item ('UUF' or 'Azure DevOps')
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if self.dry_run:
|
||||
self.log(f"[DRY-RUN] Would add @copilot comment to PR #{pr_number}")
|
||||
return True
|
||||
|
||||
try:
|
||||
rest_headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
# Build work item reference
|
||||
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"
|
||||
else:
|
||||
work_item_ref = ""
|
||||
|
||||
# Build document reference
|
||||
if file_path and not file_path.startswith("See work item") 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"
|
||||
|
||||
# Build custom instructions section
|
||||
if custom_instructions and custom_instructions.strip():
|
||||
custom_instructions_section = f"""
|
||||
**Custom AI Instructions:**
|
||||
{custom_instructions.strip()}
|
||||
|
||||
"""
|
||||
else:
|
||||
custom_instructions_section = ""
|
||||
|
||||
# Create a comment mentioning @copilot with VERY explicit instructions
|
||||
comment_body = f"""@copilot
|
||||
|
||||
{work_item_ref}{doc_ref}
|
||||
|
||||
**Instructions:**
|
||||
|
||||
Task: Update the documentation 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
|
||||
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.
|
||||
> 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.
|
||||
|
||||
{file_instruction}
|
||||
|
||||
3. Find this reference in the content:
|
||||
```
|
||||
{old_text}
|
||||
```
|
||||
|
||||
4. Use this text as guidance for the new content:
|
||||
```
|
||||
{new_text}
|
||||
```
|
||||
|
||||
5. Ensure the changes align with the context of the work item.
|
||||
|
||||
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}
|
||||
Thank you!
|
||||
"""
|
||||
|
||||
# Post the comment to the PR
|
||||
comments_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
||||
comment_data = {"body": comment_body}
|
||||
|
||||
resp = requests.post(comments_url, headers=rest_headers, json=comment_data, timeout=30)
|
||||
|
||||
if resp.status_code == 403:
|
||||
self.log("❌ Permission denied when adding comment")
|
||||
return False
|
||||
|
||||
resp.raise_for_status()
|
||||
self.log(f"✅ Added @copilot comment to PR #{pr_number}")
|
||||
self.log(" Copilot has been instructed to work on THIS PR's branch")
|
||||
return True
|
||||
|
||||
except requests.HTTPError as e:
|
||||
self.log(f"❌ HTTP Error adding comment: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error adding comment: {str(e)}")
|
||||
return False
|
||||
|
||||
def add_pr_suggestion(self, owner: str, repo: str, pr_number: int, file_path: str,
|
||||
old_text: str, new_text: str) -> bool:
|
||||
"""Add a suggested change comment to a PR
|
||||
|
||||
This creates a review comment with a code suggestion that can be applied
|
||||
with one click, keeping everything in the same PR.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if self.dry_run:
|
||||
self.log(f"[DRY-RUN] Would add suggested change to PR #{pr_number}")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Use REST API to create a review comment with suggestion
|
||||
rest_headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
# First, get the latest commit SHA from the PR
|
||||
pr_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
|
||||
resp = requests.get(pr_url, headers=rest_headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
pr_data = resp.json()
|
||||
commit_sha = pr_data["head"]["sha"]
|
||||
|
||||
self.log(f"Latest commit SHA: {commit_sha}")
|
||||
|
||||
# Get the file content to find line numbers
|
||||
file_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={commit_sha}"
|
||||
resp = requests.get(file_url, headers=rest_headers, timeout=30)
|
||||
|
||||
if resp.status_code == 404:
|
||||
self.log(f"⚠️ File not found in PR: {file_path}")
|
||||
return False
|
||||
|
||||
resp.raise_for_status()
|
||||
file_data = resp.json()
|
||||
|
||||
content = base64.b64decode(file_data["content"]).decode('utf-8')
|
||||
lines = content.split('\n')
|
||||
|
||||
# Find the line number where the old text appears
|
||||
old_text_lines = old_text.split('\n')
|
||||
start_line = None
|
||||
|
||||
for i in range(len(lines) - len(old_text_lines) + 1):
|
||||
if '\n'.join(lines[i:i+len(old_text_lines)]) == old_text:
|
||||
start_line = i + 1 # Line numbers are 1-based
|
||||
break
|
||||
|
||||
if not start_line:
|
||||
self.log("⚠️ Could not find text in file to create suggestion")
|
||||
return False
|
||||
|
||||
end_line = start_line + len(old_text_lines) - 1
|
||||
|
||||
# Create a review comment with suggested change
|
||||
suggestion_body = f"""```suggestion
|
||||
{new_text}
|
||||
```
|
||||
|
||||
**Automated Suggestion:** This change was requested in Azure DevOps work item.
|
||||
|
||||
Click "Commit suggestion" above to apply this change directly to the PR."""
|
||||
|
||||
comment_data = {
|
||||
"body": suggestion_body,
|
||||
"commit_id": commit_sha,
|
||||
"path": file_path,
|
||||
"line": end_line,
|
||||
"start_line": start_line if start_line != end_line else None,
|
||||
"start_side": "RIGHT"
|
||||
}
|
||||
|
||||
# Remove start_line if it's the same as line (single-line comment)
|
||||
if start_line == end_line:
|
||||
del comment_data["start_line"]
|
||||
|
||||
comments_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments"
|
||||
resp = requests.post(comments_url, headers=rest_headers, json=comment_data, timeout=30)
|
||||
|
||||
if resp.status_code == 403:
|
||||
self.log("❌ Permission denied when adding suggestion")
|
||||
return False
|
||||
|
||||
resp.raise_for_status()
|
||||
self.log(f"✅ Added suggested change comment to PR #{pr_number}")
|
||||
self.log(" User can click 'Commit suggestion' to apply it")
|
||||
return True
|
||||
|
||||
except requests.HTTPError as e:
|
||||
self.log(f"❌ HTTP Error adding suggestion: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
self.log(f" Response: {e.response.text[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error adding suggestion: {str(e)}")
|
||||
return False
|
||||
|
||||
def create_branch_with_placeholder(self, owner: str, repo: str, branch_name: str, instructions: str) -> bool:
|
||||
"""Create a branch with a placeholder commit using REST API
|
||||
|
||||
This creates a branch from main and adds a .copilot-instructions.md file
|
||||
so that the branch has at least one commit, allowing PR creation.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if self.dry_run:
|
||||
self.log(f"[DRY-RUN] Would create branch {branch_name} with placeholder commit")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Use REST API for branch/file creation
|
||||
rest_headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
# 1. Get the SHA of the main branch
|
||||
self.log(f"Getting SHA of main branch...")
|
||||
ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/main"
|
||||
resp = requests.get(ref_url, headers=rest_headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
main_sha = resp.json()["object"]["sha"]
|
||||
self.log(f"Main branch SHA: {main_sha}")
|
||||
|
||||
# 2. Create new branch from main
|
||||
self.log(f"Creating branch {branch_name}...")
|
||||
create_ref_url = f"https://api.github.com/repos/{owner}/{repo}/git/refs"
|
||||
create_ref_payload = {
|
||||
"ref": f"refs/heads/{branch_name}",
|
||||
"sha": main_sha
|
||||
}
|
||||
resp = requests.post(create_ref_url, headers=rest_headers, json=create_ref_payload, timeout=30)
|
||||
|
||||
# Check for permission errors
|
||||
if resp.status_code == 403:
|
||||
self.log("❌ Permission denied: GitHub token doesn't have write access to this repository")
|
||||
self.log(f" Repository: {owner}/{repo}")
|
||||
self.log(" Required permission: 'repo' scope with write access")
|
||||
self.log("")
|
||||
self.log(" Please verify:")
|
||||
self.log(" 1. Your token has the 'repo' scope enabled")
|
||||
self.log(" 2. You have write/push access to this repository")
|
||||
self.log(" 3. The repository exists and the name is correct")
|
||||
self.log("")
|
||||
self.log(" TIP: You can still create Issues (uncheck the PR checkbox)")
|
||||
return False
|
||||
|
||||
# Branch might already exist, that's okay
|
||||
if resp.status_code == 422:
|
||||
error_detail = resp.json()
|
||||
if "already exists" in str(error_detail).lower():
|
||||
self.log(f"Branch {branch_name} already exists, using existing branch")
|
||||
return True
|
||||
else:
|
||||
self.log(f"Error creating branch: {error_detail}")
|
||||
|
||||
resp.raise_for_status()
|
||||
self.log(f"✅ Branch {branch_name} created")
|
||||
|
||||
# 3. Create a placeholder file with instructions
|
||||
self.log("Creating placeholder commit with Copilot instructions...")
|
||||
file_content = f"""# Copilot Instructions
|
||||
|
||||
This is a placeholder file created to allow PR creation.
|
||||
|
||||
## Task
|
||||
{instructions}
|
||||
|
||||
Please process the instructions above and make the necessary changes to the documentation.
|
||||
|
||||
Once you've made the changes, you can delete this file.
|
||||
"""
|
||||
|
||||
encoded_content = base64.b64encode(file_content.encode('utf-8')).decode()
|
||||
|
||||
file_payload = {
|
||||
"message": f"Add Copilot instructions for {branch_name}",
|
||||
"content": encoded_content,
|
||||
"branch": branch_name
|
||||
}
|
||||
|
||||
file_url = f"https://api.github.com/repos/{owner}/{repo}/contents/.copilot-instructions.md"
|
||||
resp = requests.put(file_url, headers=rest_headers, json=file_payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
|
||||
self.log(f"✅ Placeholder commit created in branch {branch_name}")
|
||||
return True
|
||||
|
||||
except requests.HTTPError as e:
|
||||
self.log(f"❌ HTTP Error creating branch with placeholder: {e}")
|
||||
if e.response.status_code == 403:
|
||||
self.log(" Permission denied - token doesn't have write access")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"❌ Error creating branch with placeholder: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
GitHubAPI = GitHubGQL
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,850 @@
|
||||
"""
|
||||
Settings Dialog
|
||||
GUI for configuring application settings
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
import threading
|
||||
from tkinter import ttk, messagebox, scrolledtext
|
||||
from typing import Dict, Any, Optional
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
class SettingsDialog:
|
||||
"""Settings configuration dialog"""
|
||||
|
||||
def __init__(self, parent, config: Dict[str, Any], config_manager=None, cache_manager=None):
|
||||
self.parent = parent
|
||||
self.config = config.copy()
|
||||
self.config_manager = config_manager
|
||||
self.cache_manager = cache_manager
|
||||
self.result = None
|
||||
self.entries = {}
|
||||
|
||||
# Create dialog
|
||||
self.dialog = tk.Toplevel(parent)
|
||||
self.dialog.title("⚙️ Settings")
|
||||
self.dialog.geometry("900x1000")
|
||||
self.dialog.resizable(True, True)
|
||||
self.dialog.transient(parent)
|
||||
self.dialog.grab_set()
|
||||
|
||||
self._create_widgets()
|
||||
self._bind_events()
|
||||
|
||||
def _create_widgets(self):
|
||||
"""Create dialog widgets"""
|
||||
# Main frame with scrollbar
|
||||
main_frame = ttk.Frame(self.dialog, padding="20")
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Create notebook for tabbed settings
|
||||
notebook = ttk.Notebook(main_frame)
|
||||
notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 20))
|
||||
|
||||
# Create tabs
|
||||
self._create_general_tab(notebook)
|
||||
self._create_ai_tab(notebook)
|
||||
self._create_dataverse_tab(notebook)
|
||||
|
||||
# Buttons frame
|
||||
buttons_frame = ttk.Frame(main_frame)
|
||||
buttons_frame.pack(fill=tk.X, pady=(10, 0))
|
||||
|
||||
# Buttons
|
||||
ttk.Button(buttons_frame, text="💾 Save Settings", command=self._save_clicked).pack(side=tk.RIGHT, padx=(5, 0))
|
||||
ttk.Button(buttons_frame, text="❌ Cancel", command=self._cancel_clicked).pack(side=tk.RIGHT)
|
||||
ttk.Button(buttons_frame, text="🗑️ Clear Cache", command=self._clear_cache).pack(side=tk.LEFT, padx=(5, 0))
|
||||
ttk.Button(buttons_frame, text="Test Connection", command=self._test_connection).pack(side=tk.LEFT)
|
||||
|
||||
# Center dialog after everything is created
|
||||
self._center_dialog()
|
||||
|
||||
def _create_general_tab(self, notebook):
|
||||
"""Create general settings tab"""
|
||||
general_frame = ttk.Frame(notebook)
|
||||
notebook.add(general_frame, text="General")
|
||||
|
||||
# Scrollable frame
|
||||
canvas = tk.Canvas(general_frame)
|
||||
scrollbar = ttk.Scrollbar(general_frame, orient="vertical", command=canvas.yview)
|
||||
scrollable_frame = ttk.Frame(canvas)
|
||||
|
||||
scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
)
|
||||
|
||||
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Configure column weights for proper expansion
|
||||
scrollable_frame.columnconfigure(1, weight=1)
|
||||
|
||||
current_row = 0
|
||||
|
||||
# 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
|
||||
|
||||
# GitHub section
|
||||
self._create_section_header(scrollable_frame, current_row, "🐙 GitHub Configuration")
|
||||
current_row += 1
|
||||
|
||||
self._create_label_entry(scrollable_frame, current_row, "Personal Access Token:", 'GITHUB_PAT', password=True, width=60)
|
||||
current_row += 1
|
||||
|
||||
self._create_label_entry(scrollable_frame, current_row, "Target Repository (owner/repo):", 'GITHUB_REPO', width=60)
|
||||
current_row += 1
|
||||
|
||||
self._create_forked_repo_dropdown(scrollable_frame, current_row)
|
||||
current_row += 1
|
||||
|
||||
# General options section
|
||||
self._create_section_header(scrollable_frame, current_row, "⚙️ General Options")
|
||||
current_row += 1
|
||||
|
||||
self._create_dry_run_checkbox(scrollable_frame, current_row)
|
||||
current_row += 1
|
||||
|
||||
self._create_label_entry(scrollable_frame, current_row, "Local Repo Path:", 'LOCAL_REPO_PATH', width=60)
|
||||
current_row += 1
|
||||
|
||||
# Detected repos dropdown
|
||||
ttk.Label(scrollable_frame, text="Detected Repos:", font=('Arial', 10, 'bold')).grid(
|
||||
row=current_row, column=0, sticky=tk.W, pady=5, padx=10)
|
||||
|
||||
# Frame for dropdown and refresh button
|
||||
detected_frame = ttk.Frame(scrollable_frame)
|
||||
detected_frame.grid(row=current_row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
|
||||
self.detected_repos_var = tk.StringVar(value='Scanning...')
|
||||
self.detected_repos_dropdown = ttk.Combobox(detected_frame, textvariable=self.detected_repos_var,
|
||||
state='readonly', width=45)
|
||||
self.detected_repos_dropdown.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
self.detected_repos_dropdown.bind('<<ComboboxSelected>>', self._on_repo_selected)
|
||||
|
||||
refresh_button = ttk.Button(detected_frame, text="🔄 Scan", command=self._scan_repos, width=8)
|
||||
refresh_button.pack(side=tk.LEFT, padx=(5, 0))
|
||||
current_row += 1
|
||||
|
||||
# Help text for local repo path
|
||||
repo_help = ttk.Label(scrollable_frame,
|
||||
text="💡 Repository Setup Guide:\n"
|
||||
" • Local Repo Path: Where your fork repos are cloned (e.g., C:\\git\\repos)\n"
|
||||
" • Detected Repos: Shows your local fork (e.g., yourname/repo)\n"
|
||||
" • Target Repository: Upstream repo for PRs (e.g., microsoft/repo)\n"
|
||||
" • Fork Workflow: Work on your fork locally, create PRs to upstream",
|
||||
font=('Arial', 9), foreground='gray', justify=tk.LEFT, wraplength=850)
|
||||
repo_help.grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 20), padx=10)
|
||||
current_row += 1
|
||||
|
||||
# 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"
|
||||
" • 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",
|
||||
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)
|
||||
|
||||
# Scan for repos after creating the UI
|
||||
self.dialog.after(100, self._scan_repos)
|
||||
|
||||
# Pack canvas and scrollbar
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
def _create_ai_tab(self, notebook):
|
||||
"""Create AI settings tab"""
|
||||
ai_frame = ttk.Frame(notebook)
|
||||
notebook.add(ai_frame, text="AI Providers")
|
||||
|
||||
# Scrollable frame
|
||||
canvas = tk.Canvas(ai_frame)
|
||||
scrollbar = ttk.Scrollbar(ai_frame, orient="vertical", command=canvas.yview)
|
||||
scrollable_frame = ttk.Frame(canvas)
|
||||
|
||||
scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
)
|
||||
|
||||
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# AI Provider section
|
||||
self._create_section_header(scrollable_frame, 0, "🤖 AI Provider Configuration")
|
||||
|
||||
# Provider dropdown
|
||||
ttk.Label(scrollable_frame, text="AI Provider:", font=('Arial', 10, 'bold')).grid(
|
||||
row=1, column=0, sticky=tk.W, pady=5, padx=10)
|
||||
|
||||
self.ai_provider_var = tk.StringVar(value=self.config.get('AI_PROVIDER', 'none'))
|
||||
provider_dropdown = ttk.Combobox(scrollable_frame, textvariable=self.ai_provider_var,
|
||||
values=['none', 'claude', 'chatgpt', 'github-copilot'], 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
|
||||
|
||||
# API Keys
|
||||
self._create_label_entry(scrollable_frame, 2, "Claude API Key:", 'CLAUDE_API_KEY', password=True)
|
||||
self._create_label_entry(scrollable_frame, 3, "ChatGPT API Key:", 'OPENAI_API_KEY', password=True)
|
||||
self._create_label_entry(scrollable_frame, 4, "GitHub Token (for Copilot) [defaults to GitHub PAT]:", 'GITHUB_TOKEN', password=True)
|
||||
|
||||
# Help text
|
||||
help_text = ttk.Label(scrollable_frame, text="\n💡 Tips:\n"
|
||||
"• Provider: Choose 'none' to disable AI (uses Copilot workflow)\n"
|
||||
"• Claude: Get key at console.anthropic.com\n"
|
||||
"• ChatGPT: Get key at platform.openai.com/api-keys\n"
|
||||
"• GitHub Copilot: Uses GitHub Models API (requires GitHub token)\n"
|
||||
"• GitHub Token: Auto-defaults to GitHub PAT if left empty\n"
|
||||
"• Cost: ~$0.01-0.05 per PR with AI, free with 'none'\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)
|
||||
|
||||
# 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")
|
||||
|
||||
def _create_section_header(self, parent, row: int, text: str):
|
||||
"""Create a section header"""
|
||||
header_frame = ttk.Frame(parent)
|
||||
header_frame.grid(row=row, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(20, 10), padx=10)
|
||||
header_frame.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(header_frame, text=text, font=('Arial', 12, 'bold')).grid(row=0, column=0, sticky=tk.W)
|
||||
ttk.Separator(header_frame, orient='horizontal').grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0))
|
||||
|
||||
def _create_label_entry(self, parent, row: int, label_text: str, config_key: str,
|
||||
password: bool = False, width: int = 50, multiline: bool = False):
|
||||
"""Create a label and entry pair"""
|
||||
ttk.Label(parent, text=label_text, font=('Arial', 10, 'bold')).grid(
|
||||
row=row, column=0, sticky=tk.W, pady=5, padx=10)
|
||||
|
||||
if multiline:
|
||||
entry = scrolledtext.ScrolledText(parent, height=3, width=width)
|
||||
entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
entry.insert('1.0', self.config.get(config_key, '') or '')
|
||||
elif password:
|
||||
entry = ttk.Entry(parent, show="*", width=width)
|
||||
entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
|
||||
# Special handling for GITHUB_TOKEN - show placeholder if using default
|
||||
if config_key == 'GITHUB_TOKEN':
|
||||
github_token = self.config.get('GITHUB_TOKEN', '').strip()
|
||||
github_pat = self.config.get('GITHUB_PAT', '').strip()
|
||||
if not github_token and github_pat:
|
||||
# Show placeholder for defaulted value, but don't actually set it
|
||||
entry.config(foreground='gray')
|
||||
entry.insert(0, '(using GitHub PAT)')
|
||||
|
||||
# Add event handlers to clear placeholder on focus
|
||||
def on_focus_in(event):
|
||||
if entry.get() == '(using GitHub PAT)':
|
||||
entry.delete(0, tk.END)
|
||||
entry.config(foreground='black')
|
||||
|
||||
def on_focus_out(event):
|
||||
if not entry.get():
|
||||
entry.config(foreground='gray')
|
||||
entry.insert(0, '(using GitHub PAT)')
|
||||
|
||||
entry.bind('<FocusIn>', on_focus_in)
|
||||
entry.bind('<FocusOut>', on_focus_out)
|
||||
else:
|
||||
entry.insert(0, github_token)
|
||||
else:
|
||||
entry.insert(0, self.config.get(config_key, '') or '')
|
||||
else:
|
||||
entry = ttk.Entry(parent, width=width)
|
||||
entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
entry.insert(0, self.config.get(config_key, '') or '')
|
||||
|
||||
self.entries[config_key] = entry
|
||||
parent.columnconfigure(1, weight=1)
|
||||
|
||||
def _create_forked_repo_dropdown(self, parent, row: int):
|
||||
"""Create forked repository dropdown with local repo detection"""
|
||||
ttk.Label(parent, text="Forked Repository:", font=('Arial', 10, 'bold')).grid(
|
||||
row=row, column=0, sticky=tk.W, pady=5, padx=10)
|
||||
|
||||
# Frame for dropdown and refresh button
|
||||
dropdown_frame = ttk.Frame(parent)
|
||||
dropdown_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5, padx=10)
|
||||
dropdown_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Initial options
|
||||
repo_options = [''] # Empty option
|
||||
|
||||
# Add local repositories
|
||||
local_repo_path = self.config.get('LOCAL_REPO_PATH', '')
|
||||
if local_repo_path:
|
||||
try:
|
||||
from .utils import LocalRepositoryScanner
|
||||
local_repos = LocalRepositoryScanner.scan_local_repos(local_repo_path)
|
||||
if local_repos:
|
||||
repo_options.append('--- Local Repositories ---')
|
||||
repo_options.extend(local_repos)
|
||||
except Exception as e:
|
||||
print(f"Error scanning local repos: {e}")
|
||||
|
||||
# Placeholder for user's forks (will be populated asynchronously)
|
||||
self.forked_repos = []
|
||||
|
||||
self.forked_repo_var = tk.StringVar(value=self.config.get('FORKED_REPO', ''))
|
||||
self.forked_repo_dropdown = ttk.Combobox(dropdown_frame, textvariable=self.forked_repo_var,
|
||||
values=repo_options, width=50)
|
||||
self.forked_repo_dropdown.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5))
|
||||
self.entries['FORKED_REPO'] = self.forked_repo_var
|
||||
|
||||
# Refresh button
|
||||
refresh_btn = ttk.Button(dropdown_frame, text="🔄", width=3,
|
||||
command=self._refresh_forked_repos)
|
||||
refresh_btn.grid(row=0, column=1)
|
||||
|
||||
# Help text for forked repo
|
||||
help_label = ttk.Label(parent,
|
||||
text=" ℹ️ Your fork where changes will be made. Leave empty to auto-detect from document URL.",
|
||||
font=('Arial', 9), foreground='gray')
|
||||
help_label.grid(row=row+1, column=0, columnspan=3, sticky=tk.W, padx=10)
|
||||
|
||||
# Start async loading of user's forks
|
||||
self.dialog.after(100, self._load_user_forks_async)
|
||||
|
||||
def _refresh_forked_repos(self):
|
||||
"""Refresh the forked repositories dropdown"""
|
||||
self._load_user_forks_async()
|
||||
|
||||
# Also refresh local repos
|
||||
local_repo_path = self.config.get('LOCAL_REPO_PATH', '')
|
||||
if local_repo_path:
|
||||
try:
|
||||
from .utils import LocalRepositoryScanner
|
||||
local_repos = LocalRepositoryScanner.scan_local_repos(local_repo_path)
|
||||
|
||||
# Update dropdown with current values plus refreshed local repos
|
||||
current_values = list(self.forked_repo_dropdown['values'])
|
||||
|
||||
# Remove old local repos section
|
||||
if '--- Local Repositories ---' in current_values:
|
||||
start_idx = current_values.index('--- Local Repositories ---')
|
||||
# Find where GitHub repos start or end of list
|
||||
end_idx = len(current_values)
|
||||
for i in range(start_idx + 1, len(current_values)):
|
||||
if current_values[i].startswith('--- ') and 'GitHub' in current_values[i]:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
# Remove local repos section
|
||||
current_values = current_values[:start_idx] + current_values[end_idx:]
|
||||
|
||||
# Add refreshed local repos
|
||||
if local_repos:
|
||||
current_values.insert(1, '--- Local Repositories ---')
|
||||
for i, repo in enumerate(local_repos):
|
||||
current_values.insert(2 + i, repo)
|
||||
|
||||
self.forked_repo_dropdown['values'] = current_values
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error refreshing local repos: {e}")
|
||||
|
||||
def _load_user_forks_async(self):
|
||||
"""Load user's GitHub forks asynchronously"""
|
||||
def load_forks():
|
||||
try:
|
||||
github_token = self.config.get('GITHUB_PAT', '')
|
||||
if not github_token:
|
||||
return
|
||||
|
||||
from .github_api import GitHubGQL
|
||||
github_api = GitHubGQL(github_token, dry_run=False)
|
||||
self.forked_repos = github_api.get_user_forks()
|
||||
|
||||
# Update dropdown on main thread
|
||||
if hasattr(self, 'dialog') and self.dialog.winfo_exists():
|
||||
self.dialog.after(0, self._update_forked_dropdown)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading user forks: {e}")
|
||||
|
||||
threading.Thread(target=load_forks, daemon=True).start()
|
||||
|
||||
def _update_forked_dropdown(self):
|
||||
"""Update the forked repository dropdown with GitHub forks"""
|
||||
try:
|
||||
# Check if dialog and dropdown still exist
|
||||
if not hasattr(self, 'dialog') or not self.dialog.winfo_exists():
|
||||
return
|
||||
if not hasattr(self, 'forked_repo_dropdown') or not self.forked_repo_dropdown.winfo_exists():
|
||||
return
|
||||
|
||||
current_values = list(self.forked_repo_dropdown['values'])
|
||||
|
||||
# Remove old GitHub forks section if exists
|
||||
if '--- Your GitHub Forks ---' in current_values:
|
||||
start_idx = current_values.index('--- Your GitHub Forks ---')
|
||||
current_values = current_values[:start_idx]
|
||||
|
||||
# Add GitHub forks section
|
||||
if self.forked_repos:
|
||||
current_values.append('--- Your GitHub Forks ---')
|
||||
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 _create_dry_run_checkbox(self, parent, row: int):
|
||||
"""Create dry run checkbox"""
|
||||
self.dry_run_var = tk.BooleanVar()
|
||||
dry_run_value = self.config.get('DRY_RUN', 'false')
|
||||
self.dry_run_var.set(str(dry_run_value).lower() in ('true', '1', 'yes', 'on'))
|
||||
|
||||
dry_run_frame = ttk.Frame(parent)
|
||||
dry_run_frame.grid(row=row, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10, padx=10)
|
||||
|
||||
dry_run_checkbox = ttk.Checkbutton(
|
||||
dry_run_frame,
|
||||
text="🧪 Dry Run Mode (Test without making changes)",
|
||||
variable=self.dry_run_var
|
||||
)
|
||||
dry_run_checkbox.pack(side=tk.LEFT)
|
||||
|
||||
help_label = ttk.Label(dry_run_frame,
|
||||
text=" ℹ️ Simulates operations without creating actual GitHub issues/PRs",
|
||||
font=('Arial', 9), foreground='gray')
|
||||
help_label.pack(side=tk.LEFT)
|
||||
|
||||
self.entries['DRY_RUN'] = self.dry_run_var
|
||||
|
||||
def _scan_repos(self):
|
||||
"""Scan work items to detect commonly used repositories"""
|
||||
try:
|
||||
# This is a placeholder - could be enhanced to actually scan work items
|
||||
# and suggest repositories based on document URLs found
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Could not scan repositories: {e}")
|
||||
|
||||
def _bind_events(self):
|
||||
"""Bind keyboard events"""
|
||||
self.dialog.bind('<Return>', lambda e: self._save_clicked())
|
||||
self.dialog.bind('<Escape>', lambda e: self._cancel_clicked())
|
||||
|
||||
# Set focus to first entry if available
|
||||
if self.entries:
|
||||
first_entry = next(iter(self.entries.values()))
|
||||
if hasattr(first_entry, 'focus_set'):
|
||||
first_entry.focus_set()
|
||||
|
||||
def _test_connection(self):
|
||||
"""Test connection to configured services"""
|
||||
# Get current values
|
||||
config_values = self._get_config_values()
|
||||
|
||||
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")
|
||||
|
||||
# Test GitHub
|
||||
if config_values.get('GITHUB_PAT'):
|
||||
try:
|
||||
# Try to import and test GitHub API
|
||||
from .github_api import GitHubAPI
|
||||
api = GitHubAPI(config_values.get('GITHUB_PAT'))
|
||||
|
||||
# Basic connection test
|
||||
results.append("GitHub: ✅ Token configured")
|
||||
|
||||
if config_values.get('GITHUB_REPO'):
|
||||
results.append(f"GitHub Repository: ✅ {config_values.get('GITHUB_REPO')}")
|
||||
else:
|
||||
results.append("GitHub Repository: ⚠️ Not configured")
|
||||
|
||||
except ImportError:
|
||||
results.append("GitHub: ⚠️ Token set (API module not available)")
|
||||
except Exception as e:
|
||||
results.append(f"GitHub: ❌ Error - {str(e)}")
|
||||
else:
|
||||
results.append("GitHub: ❌ No token configured")
|
||||
|
||||
# Test AI Provider
|
||||
ai_provider = config_values.get('AI_PROVIDER', 'none').lower()
|
||||
if ai_provider and ai_provider != 'none':
|
||||
try:
|
||||
from .ai_manager import AIManager
|
||||
ai_manager = AIManager()
|
||||
available, missing = ai_manager.check_ai_module_availability(ai_provider)
|
||||
|
||||
if available:
|
||||
results.append(f"AI Provider ({ai_provider}): ✅ Available")
|
||||
else:
|
||||
results.append(f"AI Provider ({ai_provider}): ⚠️ Missing packages: {', '.join(missing)}")
|
||||
except ImportError:
|
||||
results.append(f"AI Provider ({ai_provider}): ⚠️ Configuration set (AI manager not available)")
|
||||
else:
|
||||
results.append("AI Provider: ℹ️ Disabled (using standard method)")
|
||||
|
||||
# Show results
|
||||
if results:
|
||||
messagebox.showinfo("Connection Test Results",
|
||||
"\n".join(results) + "\n\n💡 Full validation requires running the application.",
|
||||
parent=self.dialog)
|
||||
else:
|
||||
messagebox.showwarning("Connection Test", "No configuration to test.", parent=self.dialog)
|
||||
|
||||
def _center_dialog(self):
|
||||
"""Center the dialog over the parent window"""
|
||||
self.dialog.update_idletasks()
|
||||
|
||||
# Get parent window position and size
|
||||
self.parent.update_idletasks()
|
||||
x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (self.dialog.winfo_width() // 2)
|
||||
y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (self.dialog.winfo_height() // 2)
|
||||
|
||||
self.dialog.geometry(f"+{x}+{y}")
|
||||
|
||||
def _get_config_values(self) -> Dict[str, Any]:
|
||||
"""Get configuration values from entries"""
|
||||
config_values = {}
|
||||
|
||||
for key, widget in self.entries.items():
|
||||
if isinstance(widget, tk.BooleanVar):
|
||||
config_values[key] = 'true' if widget.get() else 'false'
|
||||
elif isinstance(widget, tk.StringVar):
|
||||
config_values[key] = widget.get().strip()
|
||||
elif isinstance(widget, scrolledtext.ScrolledText):
|
||||
config_values[key] = widget.get('1.0', tk.END).strip()
|
||||
elif isinstance(widget, ttk.Combobox):
|
||||
config_values[key] = widget.get().strip()
|
||||
else: # Entry widget
|
||||
value = widget.get().strip()
|
||||
# Special handling for GITHUB_TOKEN placeholder
|
||||
if key == 'GITHUB_TOKEN' and value == '(using GitHub PAT)':
|
||||
value = '' # Save empty string when using placeholder
|
||||
config_values[key] = value
|
||||
|
||||
return config_values
|
||||
|
||||
def _save_clicked(self):
|
||||
"""Handle save button click"""
|
||||
try:
|
||||
# Get configuration values
|
||||
config_values = self._get_config_values()
|
||||
|
||||
# Validate required fields
|
||||
required_for_basic = ['AZURE_DEVOPS_QUERY', 'AZURE_DEVOPS_PAT', '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."
|
||||
)
|
||||
return
|
||||
|
||||
# Check AI provider setup before saving
|
||||
ai_provider = config_values.get('AI_PROVIDER', '').strip().lower()
|
||||
if ai_provider and ai_provider not in ['none', '']:
|
||||
if ai_provider in ['chatgpt', 'claude', 'anthropic', 'github-copilot', 'copilot', 'github_copilot']:
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from .ai_manager import AIManager
|
||||
ai_manager = AIManager()
|
||||
|
||||
available, missing = ai_manager.check_ai_module_availability(ai_provider)
|
||||
if not available:
|
||||
# Offer to install missing packages
|
||||
install_success = ai_manager.install_ai_packages(missing, self.dialog)
|
||||
if not install_success:
|
||||
# Installation failed or was cancelled, but still save settings
|
||||
messagebox.showwarning("AI Modules Not Installed",
|
||||
f"Settings saved, but AI provider '{ai_provider}' "
|
||||
f"requires additional packages: {', '.join(missing)}\n\n"
|
||||
f"You can install them later with:\n"
|
||||
f"pip install {' '.join(missing)}",
|
||||
parent=self.dialog)
|
||||
except ImportError:
|
||||
# AIManager not available, skip AI validation
|
||||
pass
|
||||
|
||||
# Save configuration using the provided config manager
|
||||
if self.config_manager:
|
||||
success = self.config_manager.save_configuration(config_values)
|
||||
else:
|
||||
# Fallback: create new config manager or save directly to file
|
||||
try:
|
||||
from .config_manager import ConfigManager
|
||||
config_manager = ConfigManager()
|
||||
success = config_manager.save_configuration(config_values)
|
||||
except ImportError:
|
||||
# Fallback to basic file saving if ConfigManager not available
|
||||
success = self._save_to_env_file(config_values)
|
||||
|
||||
if success:
|
||||
self.result = config_values
|
||||
|
||||
# Ask user if they want to restart the application
|
||||
restart = messagebox.askyesno(
|
||||
"Settings Saved",
|
||||
"Settings have been saved to .env file!\n\n"
|
||||
"Would you like to restart the application now to apply changes?",
|
||||
parent=self.dialog
|
||||
)
|
||||
|
||||
self.dialog.destroy()
|
||||
|
||||
if restart:
|
||||
self._restart_application()
|
||||
else:
|
||||
messagebox.showerror("Save Error",
|
||||
"Failed to save settings to .env file.",
|
||||
parent=self.dialog)
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Save Error",
|
||||
f"Error saving settings:\n{str(e)}",
|
||||
parent=self.dialog)
|
||||
|
||||
def _save_to_env_file(self, config_values: Dict[str, Any]) -> bool:
|
||||
"""Fallback method to save configuration to .env file"""
|
||||
try:
|
||||
import os
|
||||
|
||||
# Create .env content
|
||||
env_content = "# Azure DevOps to GitHub Tool Configuration\n"
|
||||
env_content += "# Generated by Settings Dialog\n\n"
|
||||
|
||||
# Add all configuration values
|
||||
for key, value in config_values.items():
|
||||
if value: # Only add non-empty values
|
||||
env_content += f"{key}={value}\n"
|
||||
else:
|
||||
env_content += f"{key}=\n"
|
||||
|
||||
# Write to .env file
|
||||
env_path = os.path.join(os.getcwd(), '.env')
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.write(env_content)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving to .env file: {e}")
|
||||
return False
|
||||
|
||||
def _on_repo_selected(self, event=None):
|
||||
"""Handle repo selection from dropdown - informational only for fork workflow"""
|
||||
# The detected repo dropdown shows which FORK the AI will work on locally
|
||||
# The GITHUB_REPO field is the UPSTREAM repo where PRs are created
|
||||
# This supports the fork workflow: work on fork, PR to upstream
|
||||
pass
|
||||
|
||||
def _scan_repos(self):
|
||||
"""Scan for git repositories in the local repo path"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
# Get the local repo path from the entry field
|
||||
local_path = self.entries.get('LOCAL_REPO_PATH')
|
||||
if local_path and hasattr(local_path, 'get'):
|
||||
path_str = local_path.get().strip()
|
||||
else:
|
||||
path_str = self.config.get('LOCAL_REPO_PATH', '').strip()
|
||||
|
||||
# If no path configured, use default
|
||||
if not path_str:
|
||||
path_str = str(Path.home() / "Downloads" / "github_repos")
|
||||
|
||||
base_path = Path(path_str)
|
||||
|
||||
# Check if path exists
|
||||
if not base_path.exists():
|
||||
self.detected_repos_var.set('No repos found (directory does not exist)')
|
||||
self.detected_repos_dropdown['values'] = []
|
||||
return
|
||||
|
||||
# Scan for git repositories
|
||||
repos = []
|
||||
try:
|
||||
# Look for owner/repo structure: base_path/owner/repo/.git
|
||||
for owner_dir in base_path.iterdir():
|
||||
if not owner_dir.is_dir():
|
||||
continue
|
||||
|
||||
for repo_dir in owner_dir.iterdir():
|
||||
if not repo_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Check if it's a git repo
|
||||
git_dir = repo_dir / ".git"
|
||||
if git_dir.exists():
|
||||
repo_name = f"{owner_dir.name}/{repo_dir.name}"
|
||||
repos.append(repo_name)
|
||||
|
||||
except PermissionError:
|
||||
self.detected_repos_var.set('Permission denied accessing directory')
|
||||
self.detected_repos_dropdown['values'] = []
|
||||
return
|
||||
except Exception as e:
|
||||
self.detected_repos_var.set(f'Error scanning: {str(e)[:50]}')
|
||||
self.detected_repos_dropdown['values'] = []
|
||||
return
|
||||
|
||||
# Update dropdown
|
||||
if repos:
|
||||
repos.sort()
|
||||
self.detected_repos_dropdown['values'] = repos
|
||||
|
||||
# Auto-select if only one repo found
|
||||
if len(repos) == 1:
|
||||
self.detected_repos_var.set(repos[0])
|
||||
# Trigger the selection handler to offer auto-populating GITHUB_REPO
|
||||
self.dialog.after(200, self._on_repo_selected)
|
||||
else:
|
||||
self.detected_repos_var.set(f'{len(repos)} repo(s) found - select one')
|
||||
else:
|
||||
self.detected_repos_var.set('No git repositories found')
|
||||
self.detected_repos_dropdown['values'] = []
|
||||
|
||||
except Exception as e:
|
||||
self.detected_repos_var.set(f'Error: {str(e)[:50]}')
|
||||
self.detected_repos_dropdown['values'] = []
|
||||
|
||||
def _restart_application(self):
|
||||
"""Restart the application"""
|
||||
try:
|
||||
# Get the parent root window (main application)
|
||||
root = self.parent
|
||||
while root.master:
|
||||
root = root.master
|
||||
|
||||
# Close the main window
|
||||
root.quit()
|
||||
|
||||
# Restart the application using the same Python executable and script
|
||||
python = sys.executable
|
||||
script = sys.argv[0]
|
||||
|
||||
# If running as a module (python -m), preserve that
|
||||
if script.endswith('__main__.py'):
|
||||
# Running as module, restart with module syntax
|
||||
os.execl(python, python, '-m', 'app')
|
||||
else:
|
||||
# Running as script, restart directly
|
||||
os.execl(python, python, script, *sys.argv[1:])
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror(
|
||||
"Restart Failed",
|
||||
f"Could not restart application automatically:\n{str(e)}\n\n"
|
||||
"Please restart the application manually.",
|
||||
parent=self.parent
|
||||
)
|
||||
|
||||
def _cancel_clicked(self):
|
||||
"""Handle cancel button click"""
|
||||
self.result = None
|
||||
self.dialog.destroy()
|
||||
|
||||
def _clear_cache(self):
|
||||
"""Clear all cached work 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"
|
||||
"The next time you open the app, it will auto-load fresh data."
|
||||
)
|
||||
if result:
|
||||
try:
|
||||
# Use cache manager passed to dialog
|
||||
if self.cache_manager:
|
||||
self.cache_manager.invalidate_cache()
|
||||
messagebox.showinfo(
|
||||
"Cache Cleared",
|
||||
"All cached items have been cleared.\n"
|
||||
"Fresh data will be loaded on next app start."
|
||||
)
|
||||
else:
|
||||
messagebox.showerror("Error", "Cache manager not available")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to clear cache: {str(e)}")
|
||||
|
||||
def show(self) -> Optional[Dict[str, Any]]:
|
||||
"""Show dialog and return result"""
|
||||
self.dialog.wait_window()
|
||||
return self.result
|
||||
@@ -0,0 +1,742 @@
|
||||
"""
|
||||
Utility functions and helpers
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class Logger:
|
||||
"""Simple logger for GUI applications"""
|
||||
|
||||
def __init__(self, text_widget=None):
|
||||
self.text_widget = text_widget
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
"""Log a message to the text widget and console"""
|
||||
timestamp = __import__('datetime').datetime.now().strftime("%H:%M:%S")
|
||||
formatted_message = f"[{timestamp}] {message}"
|
||||
|
||||
try:
|
||||
print(formatted_message)
|
||||
except UnicodeEncodeError:
|
||||
# Fallback: replace Unicode emojis with ASCII equivalents
|
||||
safe_message = formatted_message.replace('✅', '[SUCCESS]').replace('❌', '[ERROR]').replace('⚠️', '[WARNING]').replace('📋', '[INFO]').replace('📄', '[FILE]').replace('📍', '[LOCATION]').replace('📝', '[EDIT]')
|
||||
print(safe_message)
|
||||
|
||||
if self.text_widget:
|
||||
def update_widget():
|
||||
try:
|
||||
with self._lock:
|
||||
self.text_widget.config(state='normal')
|
||||
self.text_widget.insert('end', formatted_message + '\n')
|
||||
self.text_widget.see('end')
|
||||
self.text_widget.config(state='disabled')
|
||||
self.text_widget.update_idletasks()
|
||||
except:
|
||||
pass # Widget might be destroyed
|
||||
|
||||
# Schedule update on main thread
|
||||
if hasattr(self.text_widget, 'after'):
|
||||
self.text_widget.after(0, update_widget)
|
||||
else:
|
||||
update_widget()
|
||||
|
||||
|
||||
class PRNumberManager:
|
||||
"""Manages PR numbers for branch naming"""
|
||||
|
||||
PR_COUNTER_FILE = '.pr_counter.json'
|
||||
|
||||
@classmethod
|
||||
def get_pr_counter_file(cls) -> str:
|
||||
"""Get the path to the PR counter file"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(script_dir, cls.PR_COUNTER_FILE)
|
||||
|
||||
@classmethod
|
||||
def load_pr_counter(cls) -> Dict[str, int]:
|
||||
"""Load the PR counter from file"""
|
||||
counter_file = cls.get_pr_counter_file()
|
||||
if os.path.exists(counter_file):
|
||||
try:
|
||||
with open(counter_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def save_pr_counter(cls, counter: Dict[str, int]) -> None:
|
||||
"""Save the PR counter to file"""
|
||||
counter_file = cls.get_pr_counter_file()
|
||||
try:
|
||||
with open(counter_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(counter, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save PR counter: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_next_pr_number(cls, provider_key: str) -> int:
|
||||
"""
|
||||
Get the next PR number for a given provider
|
||||
|
||||
Args:
|
||||
provider_key: Either the AI provider name ('chatgpt', 'claude') or 'gh_copilot'
|
||||
|
||||
Returns:
|
||||
Next available PR number for this provider
|
||||
"""
|
||||
try:
|
||||
counter = cls.load_pr_counter()
|
||||
current_number = counter.get(provider_key, 0)
|
||||
next_number = current_number + 1
|
||||
counter[provider_key] = next_number
|
||||
cls.save_pr_counter(counter)
|
||||
return next_number
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error managing PR counter: {e}")
|
||||
# Fallback to a timestamp-based number
|
||||
import time
|
||||
return int(time.time()) % 10000
|
||||
|
||||
|
||||
class GitHubInfoExtractor:
|
||||
"""Extracts GitHub repository information from URLs"""
|
||||
|
||||
@staticmethod
|
||||
def extract_github_info(doc_url: str) -> Dict[str, Any]:
|
||||
"""Extract GitHub repository information from a document URL"""
|
||||
try:
|
||||
if not doc_url or 'github.com' not in doc_url:
|
||||
return {'error': 'Not a GitHub URL'}
|
||||
|
||||
parsed = urlparse(doc_url)
|
||||
path_parts = parsed.path.strip('/').split('/')
|
||||
|
||||
if len(path_parts) < 2:
|
||||
return {'error': 'Invalid GitHub URL format'}
|
||||
|
||||
owner = path_parts[0]
|
||||
repo = path_parts[1]
|
||||
|
||||
# Try to extract file path if it's a blob URL
|
||||
file_path = None
|
||||
if len(path_parts) > 3 and path_parts[2] == 'blob':
|
||||
# Skip branch name and get file path
|
||||
if len(path_parts) > 4:
|
||||
file_path = '/'.join(path_parts[4:])
|
||||
|
||||
result = {
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'original_content_git_url': doc_url
|
||||
}
|
||||
|
||||
if file_path:
|
||||
result['file_path'] = file_path
|
||||
|
||||
# Try to find ms.author from the URL or repo name
|
||||
ms_author = GitHubInfoExtractor._extract_ms_author(owner, repo, doc_url)
|
||||
if ms_author:
|
||||
result['ms_author'] = ms_author
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {'error': f'Error parsing GitHub URL: {str(e)}'}
|
||||
|
||||
@staticmethod
|
||||
def _extract_ms_author(owner: str, repo: str, url: str) -> Optional[str]:
|
||||
"""Try to extract ms.author from various sources"""
|
||||
try:
|
||||
# Method 1: Check if owner looks like a Microsoft username
|
||||
if owner.startswith('Microsoft') or 'microsoft' in owner.lower():
|
||||
# Try to extract from repo name or URL patterns
|
||||
if '-' in repo:
|
||||
parts = repo.split('-')
|
||||
for part in parts:
|
||||
if len(part) > 2 and part.islower():
|
||||
return part
|
||||
|
||||
# Method 2: Look for patterns in the URL
|
||||
url_lower = url.lower()
|
||||
|
||||
# Common patterns for ms.author
|
||||
patterns = [
|
||||
r'/([a-z][a-z0-9-]+[a-z0-9])/', # username-like patterns
|
||||
r'author[=:]([a-z][a-z0-9-]+)', # author= or author: patterns
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, url_lower)
|
||||
if match:
|
||||
candidate = match.group(1)
|
||||
# Validate it looks like a reasonable username
|
||||
if 3 <= len(candidate) <= 20 and candidate.replace('-', '').isalnum():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class WorkItemFieldExtractor:
|
||||
"""Extracts and processes work item fields"""
|
||||
|
||||
@staticmethod
|
||||
def extract_work_item_fields(work_item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract and process fields from Azure DevOps work item"""
|
||||
fields = work_item.get('fields', {})
|
||||
|
||||
# Extract basic fields
|
||||
item_id = work_item.get('id', 'Unknown')
|
||||
title = fields.get('System.Title', 'No Title')
|
||||
|
||||
# Extract custom fields with fallbacks
|
||||
nature_of_request = (
|
||||
fields.get('Custom.Natureofrequest') or
|
||||
fields.get('Custom.NatureOfRequest') or
|
||||
fields.get('Microsoft.VSTS.Common.DescriptionHtml', '')
|
||||
)
|
||||
|
||||
# Clean HTML if present
|
||||
if nature_of_request and '<' in nature_of_request:
|
||||
nature_of_request = WorkItemFieldExtractor._clean_html(nature_of_request)
|
||||
|
||||
mydoc_url = (
|
||||
fields.get('Custom.MyDocURL') or
|
||||
fields.get('Custom.DocumentURL') or
|
||||
fields.get('Custom.URL', '')
|
||||
)
|
||||
|
||||
text_to_change = (
|
||||
fields.get('Custom.TextToChange') or
|
||||
fields.get('Custom.CurrentText', '')
|
||||
)
|
||||
|
||||
new_text = (
|
||||
fields.get('Custom.NewText') or
|
||||
fields.get('Custom.ProposedText') or
|
||||
fields.get('Custom.ReplacementText', '')
|
||||
)
|
||||
|
||||
# Extract GitHub info from the document URL
|
||||
github_info = GitHubInfoExtractor.extract_github_info(mydoc_url)
|
||||
|
||||
return {
|
||||
'id': item_id,
|
||||
'title': title,
|
||||
'nature_of_request': nature_of_request,
|
||||
'mydoc_url': mydoc_url,
|
||||
'text_to_change': text_to_change,
|
||||
'new_text': new_text,
|
||||
'github_info': github_info,
|
||||
'status': 'Ready',
|
||||
'source': 'Azure DevOps'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def extract_uuf_item_fields(uuf_item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract and process fields from UUF item"""
|
||||
# UUF items have different field structure
|
||||
item_id = uuf_item.get('cr_uufitemid', 'Unknown')
|
||||
title = uuf_item.get('cr_title', 'No Title')
|
||||
|
||||
nature_of_request = uuf_item.get('cr_description', '')
|
||||
mydoc_url = uuf_item.get('cr_documenturl', '')
|
||||
text_to_change = uuf_item.get('cr_currenttext', '')
|
||||
new_text = uuf_item.get('cr_newtext', '')
|
||||
|
||||
# Extract GitHub info
|
||||
github_info = GitHubInfoExtractor.extract_github_info(mydoc_url)
|
||||
|
||||
return {
|
||||
'id': item_id,
|
||||
'title': title,
|
||||
'nature_of_request': nature_of_request,
|
||||
'mydoc_url': mydoc_url,
|
||||
'text_to_change': text_to_change,
|
||||
'new_text': new_text,
|
||||
'github_info': github_info,
|
||||
'status': 'Ready',
|
||||
'source': 'UUF'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _clean_html(html_text: str) -> str:
|
||||
"""Remove HTML tags and decode entities"""
|
||||
import html
|
||||
|
||||
# Remove HTML tags
|
||||
clean_text = re.sub(r'<[^>]+>', '', html_text)
|
||||
|
||||
# Decode HTML entities
|
||||
clean_text = html.unescape(clean_text)
|
||||
|
||||
# Clean up whitespace
|
||||
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
|
||||
|
||||
return clean_text
|
||||
|
||||
|
||||
class ContentBuilders:
|
||||
"""Builds content for GitHub issues and PRs"""
|
||||
|
||||
@staticmethod
|
||||
def build_issue_title(item: Dict[str, Any]) -> str:
|
||||
"""Build GitHub issue title"""
|
||||
source_prefix = "UUF" if item.get('source') == 'UUF' else "AB"
|
||||
return f"[{source_prefix}#{item['id']}] {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("")
|
||||
|
||||
# Make ID a hyperlink if source URL is available
|
||||
if item.get('source_url'):
|
||||
body_parts.append(f"**ID:** [{item['id']}]({item['source_url']})")
|
||||
else:
|
||||
body_parts.append(f"**ID:** {item['id']}")
|
||||
|
||||
body_parts.append(f"**Title:** {item['title']}")
|
||||
body_parts.append("")
|
||||
|
||||
# Nature of request
|
||||
if item['nature_of_request']:
|
||||
body_parts.append("**Nature of Request:**")
|
||||
body_parts.append(item['nature_of_request'])
|
||||
body_parts.append("")
|
||||
|
||||
# Document information
|
||||
if item['mydoc_url']:
|
||||
body_parts.append("**Document URL:**")
|
||||
body_parts.append(item['mydoc_url'])
|
||||
body_parts.append("")
|
||||
|
||||
# Change details
|
||||
body_parts.append("## Change Details")
|
||||
body_parts.append("")
|
||||
|
||||
if item['text_to_change']:
|
||||
body_parts.append("**Text to Change:**")
|
||||
body_parts.append("```")
|
||||
body_parts.append(item['text_to_change'])
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
if item['new_text']:
|
||||
body_parts.append("**Proposed New Text:**")
|
||||
body_parts.append("```")
|
||||
body_parts.append(item['new_text'])
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
|
||||
# Repository info
|
||||
if github_info.get('owner') and github_info.get('repo'):
|
||||
body_parts.append("## Repository Information")
|
||||
body_parts.append("")
|
||||
body_parts.append(f"**Repository:** {github_info['owner']}/{github_info['repo']}")
|
||||
|
||||
if github_info.get('ms_author'):
|
||||
body_parts.append(f"**Author:** @{github_info['ms_author']}")
|
||||
|
||||
body_parts.append("")
|
||||
|
||||
# Instructions for manual review
|
||||
body_parts.append("## Instructions")
|
||||
body_parts.append("")
|
||||
body_parts.append("This issue requires manual review of the proposed documentation change.")
|
||||
body_parts.append("")
|
||||
body_parts.append("**Next Steps:**")
|
||||
body_parts.append("1. Review the proposed change above")
|
||||
body_parts.append("2. Navigate to the document URL")
|
||||
body_parts.append("3. Locate the text that needs to be changed")
|
||||
body_parts.append("4. Make the appropriate updates")
|
||||
body_parts.append("5. Close this issue when complete")
|
||||
body_parts.append("")
|
||||
body_parts.append("---")
|
||||
body_parts.append("*Created automatically by Azure DevOps → GitHub Processor*")
|
||||
|
||||
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']}"
|
||||
|
||||
@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("")
|
||||
|
||||
# Make ID a hyperlink if source URL is available
|
||||
if item.get('source_url'):
|
||||
body_parts.append(f"**ID:** [{item['id']}]({item['source_url']})")
|
||||
else:
|
||||
body_parts.append(f"**ID:** {item['id']}")
|
||||
|
||||
body_parts.append(f"**Title:** {item['title']}")
|
||||
body_parts.append("")
|
||||
|
||||
# Nature of request
|
||||
if item['nature_of_request']:
|
||||
body_parts.append("**Description:**")
|
||||
body_parts.append(item['nature_of_request'])
|
||||
body_parts.append("")
|
||||
|
||||
# Change summary
|
||||
body_parts.append("## Changes Made")
|
||||
body_parts.append("")
|
||||
body_parts.append("This PR updates documentation as requested.")
|
||||
body_parts.append("")
|
||||
|
||||
if item['text_to_change'] and item['new_text']:
|
||||
body_parts.append("**Change Summary:**")
|
||||
body_parts.append("- Updated specific text content as requested")
|
||||
body_parts.append("")
|
||||
|
||||
body_parts.append("<details>")
|
||||
body_parts.append("<summary>View Change Details</summary>")
|
||||
body_parts.append("")
|
||||
body_parts.append("**Original Text:**")
|
||||
body_parts.append("```")
|
||||
body_parts.append(item['text_to_change'])
|
||||
body_parts.append("```")
|
||||
body_parts.append("")
|
||||
body_parts.append("**New Text:**")
|
||||
body_parts.append("```")
|
||||
body_parts.append(item['new_text'])
|
||||
body_parts.append("```")
|
||||
body_parts.append("</details>")
|
||||
body_parts.append("")
|
||||
|
||||
# Repository info
|
||||
if github_info.get('ms_author'):
|
||||
body_parts.append(f"**Author:** @{github_info['ms_author']}")
|
||||
body_parts.append("")
|
||||
|
||||
# Review instructions
|
||||
body_parts.append("## Review Checklist")
|
||||
body_parts.append("")
|
||||
body_parts.append("- [ ] Changes match the requested update")
|
||||
body_parts.append("- [ ] No unintended changes were made")
|
||||
body_parts.append("- [ ] Grammar and formatting are correct")
|
||||
body_parts.append("- [ ] Links and references are working")
|
||||
body_parts.append("")
|
||||
|
||||
body_parts.append("---")
|
||||
body_parts.append("*Created automatically by Azure DevOps → GitHub Processor*")
|
||||
|
||||
return "\n".join(body_parts)
|
||||
|
||||
|
||||
class LocalRepositoryScanner:
|
||||
"""Scans local repository path for Git repositories"""
|
||||
|
||||
@staticmethod
|
||||
def scan_local_repos(local_repo_path: str) -> List[str]:
|
||||
"""Scan local path for Git repositories"""
|
||||
if not local_repo_path or not os.path.exists(local_repo_path):
|
||||
return []
|
||||
|
||||
repos = []
|
||||
try:
|
||||
for item in os.listdir(local_repo_path):
|
||||
item_path = os.path.join(local_repo_path, item)
|
||||
if os.path.isdir(item_path):
|
||||
git_path = os.path.join(item_path, '.git')
|
||||
if os.path.exists(git_path):
|
||||
# Get remote origin URL to determine repo name
|
||||
repo_info = LocalRepositoryScanner.get_repo_info(item_path)
|
||||
if repo_info:
|
||||
repos.append(repo_info)
|
||||
else:
|
||||
# Fallback to folder name
|
||||
repos.append(f"local/{item}")
|
||||
except PermissionError:
|
||||
pass # Skip directories we can't access
|
||||
except Exception as e:
|
||||
print(f"Error scanning local repos: {e}")
|
||||
|
||||
return sorted(repos)
|
||||
|
||||
@staticmethod
|
||||
def get_repo_info(repo_path: str) -> Optional[str]:
|
||||
"""Get repository information from local Git repo"""
|
||||
try:
|
||||
# Get remote origin URL
|
||||
result = subprocess.run(
|
||||
['git', 'config', '--get', 'remote.origin.url'],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
url = result.stdout.strip()
|
||||
return LocalRepositoryScanner.parse_git_url(url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def parse_git_url(url: str) -> Optional[str]:
|
||||
"""Parse Git URL to extract owner/repo format"""
|
||||
try:
|
||||
# Handle GitHub URLs
|
||||
if 'github.com' in url:
|
||||
# Handle both HTTPS and SSH URLs
|
||||
if url.startswith('git@'):
|
||||
# SSH: git@github.com:owner/repo.git
|
||||
parts = url.split(':')[-1].replace('.git', '')
|
||||
return parts
|
||||
else:
|
||||
# HTTPS: https://github.com/owner/repo.git
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip('/').replace('.git', '')
|
||||
return path
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def clone_repository(repo_url: str, local_path: str, repo_name: str) -> bool:
|
||||
"""Clone a repository to local path"""
|
||||
try:
|
||||
target_path = os.path.join(local_path, repo_name.split('/')[-1])
|
||||
|
||||
if os.path.exists(target_path):
|
||||
print(f"Repository already exists at {target_path}")
|
||||
return True
|
||||
|
||||
os.makedirs(local_path, exist_ok=True)
|
||||
|
||||
result = subprocess.run(
|
||||
['git', 'clone', repo_url, target_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 minutes timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"Successfully cloned {repo_url} to {target_path}")
|
||||
return True
|
||||
else:
|
||||
print(f"Failed to clone repository: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error cloning repository: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class ConfigurationHelpers:
|
||||
"""Configuration and validation utilities"""
|
||||
|
||||
@staticmethod
|
||||
def validate_ai_provider_setup(config: Dict[str, Any], parent_window=None) -> bool:
|
||||
"""Validate AI provider setup and offer to install missing modules
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
parent_window: Parent tkinter window for dialogs
|
||||
|
||||
Returns:
|
||||
bool: True if setup is valid or user handled the issue
|
||||
"""
|
||||
ai_provider = config.get('AI_PROVIDER', '').lower()
|
||||
|
||||
if not ai_provider or ai_provider == 'none':
|
||||
return True # No AI provider selected, nothing to validate
|
||||
|
||||
try:
|
||||
# Try to import AI manager for validation
|
||||
from .ai_manager import AIManager
|
||||
ai_manager = AIManager()
|
||||
|
||||
# Check if modules are available
|
||||
available, missing = ai_manager.check_ai_module_availability(ai_provider)
|
||||
|
||||
if available:
|
||||
return True # All modules available
|
||||
|
||||
print(f"⚠️ AI Provider '{ai_provider}' selected but missing required packages: {', '.join(missing)}")
|
||||
|
||||
# Offer to install missing packages
|
||||
success = ai_manager.install_ai_packages(missing, parent_window)
|
||||
|
||||
if success:
|
||||
# Re-check availability after installation
|
||||
available, still_missing = ai_manager.check_ai_module_availability(ai_provider)
|
||||
if available:
|
||||
print(f"✅ AI Provider '{ai_provider}' is now ready to use")
|
||||
return True
|
||||
else:
|
||||
print(f"⚠️ Some packages may still be missing: {', '.join(still_missing)}")
|
||||
print("Please restart the application after installation completes")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
except ImportError:
|
||||
# AI manager not available, skip validation
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def create_default_env_file() -> bool:
|
||||
"""Create a default .env file with all settings blank"""
|
||||
try:
|
||||
default_config = """# Azure DevOps to GitHub Tool 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=
|
||||
|
||||
# Application Settings
|
||||
DRY_RUN=false
|
||||
|
||||
# AI Provider Configuration (for local PR creation with AI assistance)
|
||||
AI_PROVIDER=
|
||||
CLAUDE_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GITHUB_TOKEN=
|
||||
LOCAL_REPO_PATH=
|
||||
|
||||
# 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=
|
||||
"""
|
||||
with open('.env', 'w', encoding='utf-8') as f:
|
||||
f.write(default_config)
|
||||
|
||||
print("Created default .env file with blank values")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating default .env file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Compatibility functions for direct function access
|
||||
def get_next_pr_number(provider_key: str) -> int:
|
||||
"""Compatibility function for direct access to PR number generation"""
|
||||
return PRNumberManager.get_next_pr_number(provider_key)
|
||||
|
||||
|
||||
def validate_ai_provider_setup(config: Dict[str, Any], parent_window=None) -> bool:
|
||||
"""Compatibility function for direct access to AI provider validation"""
|
||||
return ConfigurationHelpers.validate_ai_provider_setup(config, parent_window)
|
||||
|
||||
|
||||
def create_default_env_file() -> bool:
|
||||
"""Compatibility function for direct access to .env file creation"""
|
||||
return ConfigurationHelpers.create_default_env_file()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
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,9 @@
|
||||
# Core dependencies
|
||||
requests>=2.31.0
|
||||
|
||||
# AI providers (optional - installed automatically when needed)
|
||||
anthropic>=0.18.0 # Claude AI
|
||||
openai>=1.12.0 # ChatGPT/GPT-4
|
||||
|
||||
# Git operations (required for AI functionality)
|
||||
GitPython>=3.1.40
|
||||
Reference in New Issue
Block a user