Moved the current files to pivate repo

This commit is contained in:
b-tsammmons
2025-11-11 10:09:26 -10:00
parent f9094cba96
commit f209c7bd90
20 changed files with 10107 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
# Azure DevOps → GitHub Processor (GUI)
Azure DevOps -> GitHub Processor is a small, focused GUI tool that you can use to perform the following tasks:
1. Fetches Azure DevOps work items using a query you specify and extracts documentation change information from them.
1. Creates a GitHub issues using the information and assigns the issue to GitHub Copilot, which analyzes the request, creates a branch, updates the article to fix the issue, and submits a pull request (PR) on behalf of you.
The following diagram shows you the high-level steps performed by this solution:
![High-level diagram of the Azure DevOps -> GitHub Processor solution](./media/flow-diagram.png)
The PR is assigned to the author of the article based on the author metadata in the article. The author reviews off the PR and signs on it if the changes done by Copilot look good.
The `devops_to_github.py` file in the `Devops_to_GitHub_Python/latest` folder has all the code behind this tool.
## Prerequisites
Here are the prerequisites to use the tool:
- Python 3.10 or newer (the code uses modern union types like `dict | None`)
- Install packages used in the code, such as `requests` and `tkinter`, which is used for the GUI.
- GitHub Copilot must be enabled at the organization level or for the specific repository.
- You must have admin access to the repository to assign issues to Copilot.
### Azure DevOps prerequisites
- **Azure DevOps Query URL**: A shared query URL containing documentation update work items. You can use this [sample query](https://dev.azure.com/msft-skilling/Content/_queries/query/ab92a364-1376-43d5-a6da-510aa40a65d1/) for testing purposes.
Work items must contain the following fields in their description:
- **Nature of Request**: Must include **Modify existing docs**.
- **Link to doc**: Must be a URL to a Learn article.
- **Text to change**: Current text that needs to update. Can be blank.
- **Proposed new text**: Suggested replacement text. This text can be instructional such as "Remove last two links from the Related content section". In this example, the GitHub Copilot doesn't replace old text with new text. It just removes those two links.
- **Azure DevOps Personal Access Token (PAT)**: Token with work item read, write, and manage permissions. The write permission is used to write back the link to GitHub issue in the DevOps work item. For instructions on creating a token, see [Authenticate to DevOps with personal access tokens](https://learn.microsoft.com/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows#create-a-pat).
### GitHub prerequisites
- **GitHub Personal Access Token**: A **classic** token with **repo**, **admin:org**, and **copilot** permissions. For instructions on creating a token, see [Create a personal access token (classic)](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic)
## Configuration and tokens management
The app first looks for tokens in the **launch.json** file and then in the **.env** file. We recommend that you use the **.env** approach.
Create a `.env` file in the repository root and add:
```
AZURE_DEVOPS_QUERY=your_azure-devops-query-url
AZURE_DEVOPS_PAT=your_azure_devops_pat_here
GITHUB_PAT=your_github_pat_here
```
**Security reminder**: Never commit secrets. Add `.env` to `.gitignore` if you plan to store tokens locally.
### launch.json
Create or edit `.vscode/launch.json` and add the values into the `env` object for the configuration you run. Example snippet:
```json
{
"configurations": [
{
"name": "Run devops_to_github",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/POC/latest/devops_to_github.py",
"console": "integratedTerminal",
"env": {
"AZURE_DEVOPS_QUERY": "<your-azure-devops-query-url>",
"AZURE_DEVOPS_PAT": "<your-azure-pat>",
"GITHUB_PAT": "<your-github-pat>"
}
}
]
}
```
- If you use a VS Code launch configuration, do NOT put secrets in `.vscode/launch.json`.
- Instead use an env file and reference it via `"envFile": "${workspaceFolder}/.env"` or keep only placeholders in a committed `launch.json.example`.
- For CI and shared environments, use secret stores (GitHub Actions secrets, Azure Key Vault, etc.)—do not commit tokens into the repo.
**Warning:** Never commit PATs, tokens, or other secrets. If a secret is accidentally committed, rotate it immediately.
## How to run the app
Use one of the following methods:
- From a terminal (PowerShell on Windows), run the following command: `python .\POC\latest\devops_to_github.py`
- Or run the `Run`/`Debug` configuration in VS Code if you configured `launch.json` (or) `.env`.
## Using the app (overview)
1. Enter or verify the Azure DevOps Query URL and the PATs (or rely on loaded config from `launch.json` / `.env`).
1. Toggle **Dry Run**" to simulate operations (recommended for first runs).
1. Select **Fetch Work Items** to execute the Azure query and parse work items (this invokes `start_fetch_work_items` -> `fetch_work_items`).
![UI after fetching work items from Azure DevOps.](./media/fetch-work-items.png)
1. Navigate items with **← Previous** / **Next →**. Each item shows the parsed fields (`nature_of_request`, `mydoc_url`, `text_to_change`, `new_text`), detected GitHub repo (owner/repo), and `ms.author` when available.
1. Edit the **proposed new text** inline by selecting the ✏️ Edit control, make changes, then **Save**.
1. Select **🚀 CREATE ISSUE** to create an issue in the detected GitHub repo. On success you will get a hyperlink dialog with the created issue URL.
![UI after creating a GitHub issue.](./media/issue-created.png)
Here's what the tool does when you select **Create issue**
- Uses GitHub GraphQL API to fetch the repository ID and call `createIssue`.
- Attempts to locate a suggested actor named like Copilot and assign the new issue to that actor.
- Includes an `AB#<work item id>` line in the issue body to trace back to the Azure work item.
- If available, calls Azure DevOps API to add a hyperlink back to the Azure work item referencing the new GitHub issue URL.
1. Select the link to navigate to the GitHub issue to do the following tasks. In your Azure DevOps work item, you should see a link to the GitHub issue in the **GitHub issue** section as well.
- Review the details
- Notice that the GitHub issue is assigned to a Copilot
- The Copilot has created a pull request.
- A link back to Azure DevOps work item
![GitHub issue UI.](./media/github-issue-copilot-pr.png)
1. Select the pull request to review changes, and sign off on the PR after a successful build and the changes look good to you.
![GitHub Pull Request UI.](./media/github-pull-request.png)
## Dry-run and logging
Dry-run prints GraphQL payloads and simulates the behavior without performing remote mutations. Inspect the "Processing Log" tab to see payloads and debug messages.
## Notes, limitations & troubleshooting
- The UI currently focuses on creating issues. There is a GraphQL `createPullRequest` function implemented, but the PR option is not exposed in the GUI at the moment.
- The work item description parsing is opinionated and expects fields like "Nature of Request:", "Link to Doc:", "Text to Change:", and "Proposed new text:". If parsing fails, the work item will be skipped.
- Document URL extraction relies on the document's HTML metadata (`original_content_git_url` or similar). If the metadata isn't present or the URL is not a GitHub URL, the tool cannot determine owner/repo.
- Common errors:
- Invalid query URL: ensure the query URL uses `_queries/query/<id>/` or includes `queryId=`
- Token permission errors: check PAT scopes
- Network timeouts: check VPN/firewall rules and increase timeouts in code if needed
## Developer notes
- File of interest: `POC/latest/devops_to_github.py` (main GUI + logic)
- Logging is visible in the GUI under "Processing Log". The app is single-process with background threads for network calls.
- Python typing uses modern syntax (requires Python 3.10+).
## Process flow (diagram)
The following diagram illustrates the end-to-end processing flow implemented by `POC/latest/devops_to_github.py`. It shows how the application fetches Azure DevOps work items, validates and parses each item, discovers the target GitHub repository from the document metadata, and then creates and (optionally) assigns and links the resulting GitHub issue back to the Azure DevOps work item.
![Hack2025 processing flow](Devops_to_GitHub_Python/latest/Hack2025_flow.png)
High-level steps shown in the diagram:
1. User launches the GUI and provides the Azure DevOps query URL and tokens.
1. The app validates the query URL and executes the query to fetch work item IDs.
1. For each work item the app fetches details and parses the description for the required fields (Nature of Request, Link to Doc, Text to Change, Proposed new text).
1. If the item contains a valid document URL, the tool requests the document HTML and extracts `original_content_git_url` and metadata such as `ms.author`.
1. The tool maps the document metadata to a GitHub `owner/repo`, builds an issue title and body, and uses the GitHub GraphQL API to create the issue (dry-run mode simulates this).
1. If available, Copilot (or another suggested actor) is assigned to the created issue; the tool will also attempt to add a hyperlink back to the Azure work item.
1. The GUI updates the item's processing status and shows a link to the created issue on success.
## Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit [Contributor License Agreements](https://cla.opensource.microsoft.com).
When you submit a pull request, a CLA bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
## Trademarks
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
trademarks or logos is subject to and must follow
[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general).
Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
Any use of third-party trademarks or logos are subject to those third-party's policies.
+307
View File
@@ -0,0 +1,307 @@
# MicrosoftDocFlow Tool - Setup Guide
## Overview
This tool automates the process of converting Azure DevOps work items into GitHub pull requests with AI assistance. It fetches work items, processes documentation changes, and creates pull requests with proper diffs.
## Quick Start
### Prerequisites
- **Python 3.8+** installed on your system
- **Git** installed and configured
- **Azure DevOps** account with work items
- **GitHub** account with repository access
- **AI Provider** account (optional, for enhanced processing)
### Installation
1. **Clone/Download the Repository**
```bash
git clone https://github.com/yourusername/github_automation.git
cd github_automation
```
2. **Create Virtual Environment** (Recommended)
```bash
# Create virtual environment
python -m venv venv
# Activate virtual environment
# On Windows:
venv\Scripts\activate
# On macOS/Linux:
source venv/bin/activate
```
To deactivate the environment when done:
```bash
deactivate
```
3. **Install Dependencies**
```bash
pip install -r application/requirements.txt
```
4. **Run the Application**
```bash
python application/app.py
```
### Virtual Environment Management
**Activating the environment** (when returning to the project):
- Windows: `venv\Scripts\activate`
- macOS/Linux: `source venv/bin/activate`
**Deactivating the environment** (when done working):
```bash
deactivate
```
**Why use a virtual environment?**
- **Isolation**: Keeps project dependencies separate from system Python
- **Clean installs**: Prevents conflicts with other Python projects
- **Reproducible**: Ensures consistent dependency versions
- **Safe updates**: Won't affect other projects when updating packages
## Project Structure
The project is organized as follows:
```text
github_automation/
├── application/ # Main application
│ ├── app.py # Application entry point
│ ├── requirements.txt # Python dependencies
│ └── app_components/ # Application modules
│ ├── ai_manager.py # AI provider
│ ├── azure_devops_api.py # Azure DevOps API
│ ├── cache_manager.py # Work item caching
│ ├── config_manager.py # Configuration
│ ├── dataverse_api.py # Dataverse API
│ ├── github_api.py # GitHub API client
│ ├── main_gui.py # Main GUI interface
│ ├── settings_dialog.py # Settings dialog
│ ├── utils.py # Utility functions
│ └── work_item_processor.py # Work item
├── media/ # Images and assets
├── README.md # Project overview
├── SETUP.md # This setup guide
└── LICENSE # License information
```
## Configuration
### First-Time Setup
1. **Launch the application** and click "Settings" button
2. **Configure required fields** in the Settings dialog:
#### Azure DevOps Configuration (Required)
- **Query URL**: Your Azure DevOps query URL
- Example: `https://dev.azure.com/yourorg/project/_queries/query/12345678-1234-1234-1234-123456789abc/`
- Get this from Azure DevOps by creating/opening a query and copying the URL
- **Personal Access Token**: Azure DevOps PAT with work item read permissions
- Create at: `https://dev.azure.com/yourorg/_usersSettings/tokens`
- Required scopes: Work Items (Read/Write)
#### GitHub Configuration (Required)
- **Personal Access Token**: GitHub PAT for repository access
- Create at: `https://github.com/settings/tokens`
- Required scopes: repo, workflow
- **Target Repository**: Format as `owner/repository`
- Example: `microsoft/fabric-docs`
- **Forked Repository**: Your fork of the target repository
- Example: `yourusername/fabric-docs`
- **Local Repo Path**: Directory where repositories will be/are cloned
- Example: `C:\Users\yourname\repos\`
#### AI Provider Configuration (Optional)
- **Provider**: Choose from:
- `none` - GitHub Copilot via a automatic comment on the PR
- `claude` - Anthropic Claude API
- `chatgpt` - OpenAI ChatGPT API
- `github-copilot` - GitHub Models API
- **API Keys**: Provide keys based on your chosen provider
- GitHub Token: Auto-defaults to GitHub PAT if left empty
### Configuration Tips
**Start Simple**: Configure only Azure DevOps and GitHub initially
**Test Connection**: Use "Test Connection" button to verify settings
**Cache Benefits**: Work items are cached for faster subsequent loads
**AI Enhancement**: Add AI provider later for automated text processing
## Usage Guide
### Basic Workflow
1. **Load Work Items**
- Click "Fetch Work Items" to load work items from your query
- Items are displayed in the "All Work Items" tab
- Cached items load automatically on subsequent launches
2. **Select Work Item**
- Navigate to "All Work Items" tab
- **Double-click** any work item to select it as current
- OR click "Set as Current Item" button
- Selected item appears in "Current Work Item" tab
3. **Review Work Item Details**
- **Work Item ID**: Click to open in Azure DevOps
- **Nature of Request**: Description of required changes
- **Document URL**: Target documentation URL
- **Text to Change**: Current text that needs modification
- **Proposed New Text**: Replacement text (editable)
4. **Process Changes**
- **With AI**: If AI provider is configured and the AI provider is not `none`, click "Create PR" and the selected AI will be used
- **Manual**: Click "Create PR" or "Create Issue" for manual processing / GitHub Copilot comment
- **Dry Run**: Enable for testing without actual changes (Located in settings)
### Advanced Features
#### Work Item Navigation
- **Next/Previous**: Navigate through multiple work items
- **Edit Mode**: Click "Edit" to modify proposed text
#### Git Diff Viewer
- View file changes in the "Git Diff" tab
- Shows before/after comparison
- Automatic diff generation from repository changes
#### Processing Log
- Real-time activity logging in "Processing Log" tab
- Track API calls, file operations, and errors
- Detailed workflow visibility
## Advanced Configuration
### AI Provider Setup
#### Claude (Anthropic)
1. Get API key from [console.anthropic.com](https://console.anthropic.com)
2. Set provider to `claude`
3. Enter API key in settings
4. Cost: ~$0.01-0.05 per work item
#### ChatGPT (OpenAI)
1. Get API key from [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
1. Set provider to `chatgpt`
1. Enter API key in settings
1. Cost: ~$0.01-0.05 per work item
#### GitHub Copilot
1. Ensure GitHub account has Copilot access
2. Set provider to `github-copilot`
3. GitHub Token auto-defaults to GitHub PAT
4. Uses GitHub Models API
### Repository Management
#### Local Repository Setup
- **Automatic Cloning**: Repositories are cloned automatically to Local Repo Path if AI provider is used
- **Branch Management**: Creates feature branches for each work item
- **Sync Handling**: Keeps repositories updated with upstream changes
#### Forked Repository Workflow
1. **Fork** the target repository to your GitHub account
2. **Configure** both target and forked repositories in settings
3. **Automatic**: Tool manages branch creation and PR submission
### Custom Instructions
Add custom AI instructions in settings to guide processing:
```AI Prompt
Focus on technical accuracy and clear documentation.
Ensure all code examples are properly formatted.
Include relevant cross-references to related topics.
```
## 🛠️ Troubleshooting
### Common Issues
**"No work items found"**
- Verify Azure DevOps query URL is correct
- Check PAT has work item read permissions
- Ensure query returns results in Azure DevOps
**"Repository not found"**
- Verify GitHub repository name format (`owner/repo`)
- Check GitHub PAT has repository access
- Ensure repository exists and is accessible
**"AI processing failed"**
- Verify API key is correct and has credits
- Check internet connection
- Review processing log for specific errors
**"Git operations failed"**
- Ensure Git is installed and configured
- Check Local Repo Path exists and is writable
- Verify GitHub authentication is working
### Performance Tips
**Use Cache**: Let items load from cache for faster startup
**Dry Run**: Test changes before committing
**Batch Processing**: Process multiple items in sequence
**Monitor Logs**: Watch processing log for issues
## Security Best Practices
### Token Security
- **Never commit** `.env` file to version control
- **Rotate tokens** regularly (90 days recommended)
- **Minimum permissions**: Use least privilege principle
- **Secure storage**: Store tokens in secure password manager
### Repository Access
- **Fork workflow**: Use personal forks for changes
- **Branch isolation**: Each work item gets separate branch
- **Review process**: All changes go through pull requests
## Support
### Getting Help
1. **Check logs** in Processing Log tab for detailed errors
2. **Test connections** using Settings dialog test button
3. **Review configuration** for missing or incorrect values
4. **Consult documentation** for API-specific issues
### Common Resources
- [Azure DevOps PAT Documentation](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate)
- [GitHub PAT Documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)
- [Git Configuration Guide](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup)
+125
View File
@@ -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()
+33
View File
@@ -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
+185
View File
@@ -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
+255
View File
@@ -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]
+992
View File
@@ -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
+742
View File
@@ -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., &quot; to ", &amp; 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]
+9
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB