Compare commits

36 Commits

Author SHA1 Message Date
TySS-Dev 3294a2867e Ending project 2026-05-25 20:49:06 -04:00
TySP-Dev 11e0dfec1e Fixed .gitignore 2025-11-13 22:04:41 -10:00
TySP-Dev c633412903 Fixed the .gitignore 2025-11-13 22:04:39 -10:00
Tyler 895f89acfd Delete pyproject.toml 2025-11-13 22:00:58 -10:00
TySP-Dev d3fed1cea0 Enhancing AI workflow 2025-11-13 22:00:32 -10:00
TySP-Dev 0f579c973d Optimized layout and code for building 2025-11-13 21:01:16 -10:00
b-tsammmons b5c01ac35d Fix .gitignore to correctly include config.json 2025-11-13 12:31:54 -10:00
b-tsammmons 630c600f6d Update .gitignore to include pyproject.toml and config.json; remove src/config.json 2025-11-13 12:30:15 -10:00
Tyler 1174b12ed9 Updated BUILD.md 2025-11-13 12:18:34 -10:00
b-tsammmons 9968233dee Making enhancements to make building and packaging the application possible 2025-11-13 11:32:28 -10:00
b-tsammmons 2accd790a8 Added Build 2025-11-13 00:34:19 -10:00
b-tsammmons 03a647c20f Change app folder to GitHub_Pulse 2025-11-12 23:29:23 -10:00
b-tsammmons 8cda6f14d0 Making some changes 2025-11-12 23:23:44 -10:00
b-tsammmons e6428ac812 renamed app.py to main.py for flutter - added icon.png for building simplicity 2025-11-12 22:09:54 -10:00
b-tsammmons 7c46f2fbf4 Updated img links 2025-11-12 20:58:44 -10:00
b-tsammmons a41f995c08 Updated Project Structure moved imgs 2025-11-12 20:58:15 -10:00
Tyler 80a530485d Fix header formatting for GitHub Pulse logo 2025-11-12 20:42:26 -10:00
Tyler ede56477f4 Update README to use Markdown for image
Removed HTML image tag and retained Markdown image syntax.
2025-11-12 20:42:05 -10:00
Tyler 7a8e2365d0 Add GitHub Pulse logo to README
Added an image tag for the GitHub Pulse logo.
2025-11-12 20:41:54 -10:00
Tyler 48feef56e7 Update README.md 2025-11-12 20:41:00 -10:00
b-tsammmons 75a86e7321 Merge branch 'main' of https://github.com/TySP-Dev/github_pulse 2025-11-12 20:40:32 -10:00
b-tsammmons e010b234cb updated logo 2025-11-12 20:40:09 -10:00
Tyler 23f1f4a384 Improve alt text for logo image in README
Updated image alt text for better accessibility.
2025-11-12 20:36:40 -10:00
b-tsammmons f97fd162f2 updated readme 2025-11-12 20:36:13 -10:00
b-tsammmons 780f8eafcd updated readme 2025-11-12 20:35:55 -10:00
b-tsammmons 4c540461a3 update readme 2025-11-12 20:34:49 -10:00
b-tsammmons 527e2f75d0 Added logo 2025-11-12 20:34:17 -10:00
b-tsammmons a28393de4e Updated Readme and Setup guides - Removed unused imgs 2025-11-12 19:38:26 -10:00
b-tsammmons 376bc62349 Implement AI response generation and enhance workflow item display with PR file fetching and comments 2025-11-12 16:34:14 -10:00
b-tsammmons 5ba48e4c58 Enhance SettingsDialog: Load cached Ollama models on dialog open and update dropdown options with cached data. Improve model selection restoration and caching mechanism. 2025-11-12 15:59:30 -10:00
b-tsammmons bd1c866bbb Add ProcessingLogDialog for displaying processing logs and enhance SettingsDialog with package status checks 2025-11-12 15:49:11 -10:00
b-tsammmons e5047e75a1 Refactor SettingsDialog: Remove target and forked repository dropdowns, update GitHub PAT section, and clean up async repo loading methods. Enhance WorkflowItem with data reconstruction and add fetch_comments method for GitHub issues/PRs. 2025-11-12 14:17:45 -10:00
b-tsammmons feafbc15af Updated how settings and API keys are stored - Reworked the GUI to use flet 2025-11-12 02:31:24 -10:00
b-tsammmons 3b0a003d5a Rename project from "GitHub Automation Tool" to "GitHub Pulse" 2025-11-11 22:48:04 -10:00
b-tsammmons b4fb945c72 Update application title and description to reflect branding change 2025-11-11 22:46:49 -10:00
b-tsammmons 0f41a3e750 Refactor GitHub automation tool:
- Updated WorkItemFieldExtractor to be more generic and removed Azure DevOps specific references.
- Removed the EnhancedContentBuilders class as it was specific to Azure DevOps.
- Deleted work_item_processor.py as it was no longer needed.
- Introduced workflow.py to manage GitHub workflow items (issues and pull requests) with improved structure and functionality.
- Enhanced logging and error handling across the new workflow management system.
2025-11-11 22:46:02 -10:00
44 changed files with 7387 additions and 5166 deletions
+41 -50
View File
@@ -1,6 +1,6 @@
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[codz] *.py[cod]
*$py.class *$py.class
# C extensions # C extensions
@@ -46,7 +46,7 @@ htmlcov/
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py.cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
@@ -94,35 +94,20 @@ ipython_config.py
# install all needed dependencies. # install all needed dependencies.
#Pipfile.lock #Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry # poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more # This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries. # commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock *.lock
#poetry.toml
# pdm # pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock #pdm.lock
#pdm.toml # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
.pdm-python # in version control.
.pdm-build/ # https://pdm.fming.dev/#use-with-ide
.pdm.toml
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/
@@ -136,7 +121,6 @@ celerybeat.pid
# Environments # Environments
.env .env
.envrc
.venv .venv
env/ env/
venv/ venv/
@@ -175,33 +159,40 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Abstra # Flet
# Abstra is an AI-powered process automation framework. storage/
# Ignore directories containing user credentials, local state, and settings. .flet/
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code # Build artifacts
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore build/
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore dist/
# and can be added to the global gitignore or merged into this file. However, if you prefer, src/build/
# you could uncomment the following to ignore the entire vscode folder src/dist/
# .vscode/ *.apk
*.ipa
*.app
*.exe
*.dmg
*.deb
*.rpm
*.tar.gz
# Ruff stuff: # Flutter/Flet build artifacts
.ruff_cache/ .flutter-plugins
.flutter-plugins-dependencies
.dart_tool/
android/
ios/
linux/
macos/
windows/
web/
.flet
build/
*.toml
# PyPI configuration file # Configuration (generated during build, not in git)
.pypirc # Note: pyproject.toml is now tracked for proper builds
# config.json should be user-specific
# Cursor config.json
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to src/config.json
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
+152
View File
@@ -0,0 +1,152 @@
# Build Instructions
> [!NOTE]
> Building has only been tested for **linux**.
> Please report bugs with building on any other platform.
## Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- [Git](https://git-scm.com/)
- Flet v0.28.0
- Flutter SDK v3.29.0 (Flet will install this automatically, but you can also [install it manually](https://docs.flutter.dev/get-started/install))
- [Visual Studio 2016 (Windows Users)](https://aka.ms/vs/16/release/vs_community.exe)
- Android Studio (for Android builds)
- Xcode (for iOS builds)
- Any required package managers (e.g., npm, yarn)
> [!IMPORTANT]
> Flet v0.28.9 only supports Flutter SDK v3.29.0. Using other versions may lead to build failures.
> Flutter SDK v3.29.0 only supports Visual Studio 2016 on Windows. Ensure you have the correct version installed.
## Clone the Repository
```bash
git clone https://github.com/TySP-Dev/github_pulse.git
cd github_pulse/src
```
## Python Virtual Environment
```bash
# Create virtual environment
python -m venv venv
# Activate virtual environment
# On Windows:
venv\Scripts\activate
# On macOS/Linux:
source venv/bin/activate
```
## Install Dependencies
```bash
# For dev dependencies
pip install -r requirements/requirements-dev.txt
```
```bash
# Linux Dependencies (Ubuntu/Debian)
sudo apt update
sudo apt upgrade
sudo apt install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev
```
## Setup Flutte
```bash
# Set ICU data file path (Windows example)
$env:FLUTTER_ICU_DATA_FILE="C:\path\to\flutter\bin\cache\artifacts\engine\windows-x64\icudtl.dat"
```
# Example path $env:FLUTTER_ICU_DATA_FILE="C:\Users\<username>\flutter\bin\cache\artifacts\engine\windows-x64\icudtl.dat"
```bash
# Verify Flutter installation
flutter --version
```
```bash
# Run Flutter doctor
flutter doctor
```
```bash
# Flet doctor
python -m flet.cli doctor
```
```bash
# Accept Android licenses (if building for Android)
flutter doctor --android-licenses
```
```bash
# Precache Flutter artifacts (All platforms)
flutter precache --all
```
```bash
# precache Flutter artifacts (Windows example)
flutter precache --windows
```
```bash
# precache Flutter artifacts (Linux example)
flutter precache --linux
```
```bash
# precache Flutter artifacts (MacOS example)
flutter precache --macos
```
```bash
# precache Flutter artifacts (iOS example)
flutter precache --ios
```
```bash
# precache Flutter artifacts (Android example)
flutter precache --android
```
>[!NOTE]
> Only precache the platforms you intend to build for.
## Build the Application
> [!NOTE]
> Ensure you are in the `src` directory of the project and the Python virtual environment is activated. `venv\Scripts\activate` (Windows) or `source venv/bin/activate` (macOS/Linux).
```bash
# Windows
flet build windows
```
```bash
# Linux
flet build linux
```
```bash
# Android
flet build apk
```
```bash
# macOS
flet build macos
```
```bash
# iOS
flet build ios
```
## Additional Notes
- See `README.md` for more details.
- For troubleshooting, check the logs or open an issue.
- Customize build scripts in `package.json` as needed.
+90 -135
View File
@@ -1,168 +1,123 @@
# Azure DevOps → GitHub Processor (GUI) > [!WARNING]
> This project is no longer being maintained.
Azure DevOps -> GitHub Processor is a small, focused GUI tool that you can use to perform the following tasks: # ![GitHub Pulse Logo](src/app_components/assets/pulse_logo_white_no_bkg_github.png)
1. Fetches Azure DevOps work items using a query you specify and extracts documentation change information from them. A Python-based GUI application for GitHub automation workflows and AI assisted workflows.
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: ![GitHub Pulse img](src/app_components/assets/github_pulse_img.png)
![High-level diagram of the Azure DevOps -> GitHub Processor solution](./media/flow-diagram.png) ## Pulse Workflow
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. ![Pulse Workflow img](src/app_components/assets/flow-diagram.png)
The `devops_to_github.py` file in the `Devops_to_GitHub_Python/latest` folder has all the code behind this tool. ## Project Structure
```text
github_pulse/
├── src/ # Main application directory
│ ├── main.py # Application entry point
│ ├── requirements.txt # Python dependencies
│ ├── assets/ # Images for build
│ │ ├── icon.png # Application icon
│ │ └── splash_android.png # Splash screen image
│ ├── requirements/
│ │ ├── requirements-dev.txt # Development dependencies
│ │ ├── requirements-ai.txt # AI dependencies
│ │ ├── requirements.txt # Production dependencies
│ │ └── requirements-base.txt # Base dependencies
│ └── app_components/ # Application modules
│ ├── assets/ # Images and assets
│ │ ├── flow-diagram.png # Workflow diagram
│ │ ├── github_pulse_img.png # GitHub Pulse image
│ │ ├── pulse_logo_gray_no_bkg.png # GitHub Pulse logo
│ │ ├── pulse_logo_white_no_bkg_github.png # Pulse logo
│ │ ├── pulse_logo_white_no_bkg.png # Pulse logo with background
│ │ └── pulse_logo_white_w_black_bkg.png # GitHub Pulse logo with background
│ ├── __init__.py # Package initializer
│ ├── ai_manager.py # AI provider integration
│ ├── cache_manager.py # Caching functionality
│ ├── config_manager.py # Configuration management
│ ├── github_api.py # GitHub API client
│ ├── main_gui.py # Main GUI interface
│ ├── processing_log_dialog.py # Processing log dialog
│ ├── settings_dialog.py # Settings dialog
│ ├── settings_manager.py # Settings management
│ ├── utils.py # Utility functions
│ └── workflow.py # Workflow processing
├── assets/ # Images and assets
├── README.md # Readme file
├── SETUP.md # This file
└── LICENSE # License information
```
## Prerequisites ## Prerequisites
Here are the prerequisites to use the tool:
- Python 3.10 or newer (the code uses modern union types like `dict | None`) - Python 3.8 or higher
- Install packages used in the code, such as `requests` and `tkinter`, which is used for the GUI. - Git installed and configured
- GitHub Copilot must be enabled at the organization level or for the specific repository. - GitHub account with repository access
- You must have admin access to the repository to assign issues to Copilot.
### Azure DevOps prerequisites ## Quick Start
- **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. 1. **Clone the repository**
Work items must contain the following fields in their description: ```bash
git clone https://github.com/TySP-Dev/github_pulse.git
- **Nature of Request**: Must include **Modify existing docs**. cd github_pulse/src
- **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. 2. **Create and activate virtual environment**
### launch.json ```bash
# Create virtual environment
python -m venv venv
Create or edit `.vscode/launch.json` and add the values into the `env` object for the configuration you run. Example snippet: # Activate (Windows)
venv\Scripts\activate
```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>"
}
}
]
}
# Activate (macOS/Linux)
source venv/bin/activate
``` ```
- 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. 3. **Install dependencies**
## How to run the app ```bash
Use one of the following methods: # For all production dependencies
pip install -r requirements/requirements.txt
```
- From a terminal (PowerShell on Windows), run the following command: `python .\POC\latest\devops_to_github.py` ```bash
- Or run the `Run`/`Debug` configuration in VS Code if you configured `launch.json` (or) `.env`. # For dev dependencies
pip install -r requirements/requirements-dev.txt
```
## Using the app (overview) ```bash
1. Enter or verify the Azure DevOps Query URL and the PATs (or rely on loaded config from `launch.json` / `.env`). # For ai dependencies
1. Toggle **Dry Run**" to simulate operations (recommended for first runs). pip install -r requirements/requirements-ai.txt
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) ```bash
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. # For base dependencies
1. Edit the **proposed new text** inline by selecting the ✏️ Edit control, make changes, then **Save**. pip install -r requirements/requirements-base.txt
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) 4. **Run the application**
Here's what the tool does when you select **Create issue** ```bash
- Uses GitHub GraphQL API to fetch the repository ID and call `createIssue`. python app.py
- 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) ## Configuration
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) Configuration is managed through a `.env` file or settings dialog in the application.
## Dry-run and logging See [SETUP.md](SETUP.md) for detailed setup instructions.
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 ## Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a This project welcomes contributions and suggestions. In order to contribute, please fork the repository and create a pull request.
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 ## License
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/). GNU General Public License v3.0.
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or See [LICENSE](LICENSE) file for details.
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.
+72 -178
View File
@@ -1,8 +1,8 @@
# MicrosoftDocFlow Tool - Setup Guide # GitHub Pulse - Setup Guide
## Overview ## 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. A Python-based GUI application for GitHub automation workflows.
## Quick Start ## Quick Start
@@ -10,17 +10,16 @@ This tool automates the process of converting Azure DevOps work items into GitHu
- **Python 3.8+** installed on your system - **Python 3.8+** installed on your system
- **Git** installed and configured - **Git** installed and configured
- **Azure DevOps** account with work items
- **GitHub** account with repository access - **GitHub** account with repository access
- **AI Provider** account (optional, for enhanced processing) - **libmpv** installed on your system (Linux)
### Installation ### Installation
1. **Clone/Download the Repository** 1. **Clone/Download the Repository**
```bash ```bash
git clone https://github.com/TySP-Dev/github_automation.git git clone https://github.com/TySP-Dev/github_pulse.git
cd github_automation\application cd github_pulse/src
``` ```
2. **Create Virtual Environment** (Recommended) 2. **Create Virtual Environment** (Recommended)
@@ -37,30 +36,46 @@ This tool automates the process of converting Azure DevOps work items into GitHu
source venv/bin/activate source venv/bin/activate
``` ```
To deactivate the environment when done:
```bash
deactivate
```
3. **Install Dependencies** 3. **Install Dependencies**
```bash ```bash
pip install -r requirements.txt # For all production dependencies
pip install -r requirements/requirements.txt
```
```bash
# For dev dependencies
pip install -r requirements/requirements-dev.txt
```
```bash
# For ai dependencies
pip install -r requirements/requirements-ai.txt
```
```bash
# For base dependencies
pip install -r requirements/requirements-base.txt
``` ```
4. **Run the Application** 4. **Run the Application**
```bash ```bash
python app.py python main.py
``` ```
> [!NOTE]
> It is highly recommended to use a virtual environment to manage dependencies and avoid conflicts with other Python projects on your system.
> Ensure you activate the virtual environment each time you work on or start this application.
### Virtual Environment Management ### Virtual Environment Management
**Activating the environment** (when returning to the project): **Activating the environment** (when returning to the project):
```bash
- Windows: `venv\Scripts\activate` - Windows: `venv\Scripts\activate`
- macOS/Linux: `source venv/bin/activate` - macOS/Linux: `source venv/bin/activate`
```
**Deactivating the environment** (when done working): **Deactivating the environment** (when done working):
@@ -80,24 +95,40 @@ deactivate
The project is organized as follows: The project is organized as follows:
```text ```text
github_automation/ github_pulse/
├── application/ # Main application ├── src/ # Main application directory
│ ├── app.py # Application entry point │ ├── main.py # Application entry point
│ ├── requirements.txt # Python dependencies │ ├── requirements.txt # Python dependencies
│ ├── assets/ # Images for build
│ │ ├── icon.png # Application icon
│ │ └── splash_android.png # Splash screen image
│ ├── requirements/
│ │ ├── requirements-dev.txt # Development dependencies
│ │ ├── requirements-ai.txt # AI dependencies
│ │ ├── requirements.txt # Production dependencies
│ │ └── requirements-base.txt # Base dependencies
│ └── app_components/ # Application modules │ └── app_components/ # Application modules
│ ├── ai_manager.py # AI provider │ ├── assets/ # Images and assets
├── azure_devops_api.py # Azure DevOps API │ ├── flow-diagram.png # Workflow diagram
├── cache_manager.py # Work item caching │ ├── github_pulse_img.png # GitHub Pulse image
├── config_manager.py # Configuration │ ├── pulse_logo_gray_no_bkg.png # GitHub Pulse logo
├── dataverse_api.py # Dataverse API │ ├── pulse_logo_white_no_bkg_github.png # Pulse logo
│ │ ├── pulse_logo_white_no_bkg.png # Pulse logo with background
│ │ └── pulse_logo_white_w_black_bkg.png # GitHub Pulse logo with background
│ ├── __init__.py # Package initializer
│ ├── ai_manager.py # AI provider integration
│ ├── cache_manager.py # Caching functionality
│ ├── config_manager.py # Configuration management
│ ├── github_api.py # GitHub API client │ ├── github_api.py # GitHub API client
│ ├── main_gui.py # Main GUI interface │ ├── main_gui.py # Main GUI interface
│ ├── processing_log_dialog.py # Processing log dialog
│ ├── settings_dialog.py # Settings dialog │ ├── settings_dialog.py # Settings dialog
│ ├── settings_manager.py # Settings management
│ ├── utils.py # Utility functions │ ├── utils.py # Utility functions
│ └── work_item_processor.py # Work item │ └── workflow.py # Workflow processing
├── media/ # Images and assets ├── assets/ # Images and assets
├── README.md # Project overview ├── README.md # Readme file
├── SETUP.md # This setup guide ├── SETUP.md # This file
└── LICENSE # License information └── LICENSE # License information
``` ```
@@ -106,16 +137,7 @@ github_automation/
### First-Time Setup ### First-Time Setup
1. **Launch the application** and click "Settings" button 1. **Launch the application** and click "Settings" button
2. **Configure required fields** in the Settings dialog: 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) #### GitHub Configuration (Required)
@@ -123,172 +145,46 @@ github_automation/
- Create at: `https://github.com/settings/tokens` - Create at: `https://github.com/settings/tokens`
- Required scopes: repo, workflow - Required scopes: repo, workflow
- **Target Repository**: Format as `owner/repository` - **Target Repository**: Format as `owner/repository`
- Example: `microsoft/fabric-docs` - Example: `microsoft/example-repo`
- **Forked Repository**: Your fork of the target repository - **Forked Repository**: Your fork of the target repository (if applicable)
- Example: `yourusername/fabric-docs` - Example: `yourusername/example-repo`
- **Local Repo Path**: Directory where repositories will be/are cloned - **Local Repo Path**: Directory where repositories will be/are cloned
- Example: `C:\Users\yourname\repos\` - Example: `C:\Users\yourname\repos\`
#### AI Provider Configuration (Optional) #### AI Provider Configuration (Optional)
- **Provider**: Choose from: - **Provider**: Choose from:
- `none` - GitHub Copilot via a automatic comment on the PR - `none` - No AI assistance
- `claude` - Anthropic Claude API - `claude` - Anthropic Claude API
- `chatgpt` - OpenAI ChatGPT API - `chatgpt` - OpenAI ChatGPT API
- `github-copilot` - GitHub Models API - `github-copilot` - GitHub Models API
- `ollama` - Ollama Local Models
- **Ollama Model**: Select model based on local availability
- **Ollama API Endpoint**: URL for Ollama AI server
- **API Keys**: Provide keys based on your chosen provider - **API Keys**: Provide keys based on your chosen provider
- GitHub Token: Auto-defaults to GitHub PAT if left empty
### Configuration Tips ### Configuration Tips
**Start Simple**: Configure only Azure DevOps and GitHub initially **Start Simple**: Configure only GitHub initially
**Test Connection**: Use "Test Connection" button to verify settings **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 processing
**AI Enhancement**: Add AI provider later for automated text processing **Configuration File**: Settings are saved in `config.json` for persistence
## 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 ## Security Best Practices
### Token Security ### Token Security
- **Never commit** `.env` file to version control - **Use PATs** instead of passwords
- **Rotate tokens** regularly (90 days recommended) - **Rotate tokens** regularly (90 days recommended)
- **Minimum permissions**: Use least privilege principle - **Minimum permissions**: Use least privilege principle
- **Secure storage**: Store tokens in secure password manager - **Secure storage**: Store tokens in secure password manager
- **Avoid hardcoding**: Never hardcode tokens in source code
- **Secret management**: This project uses `keyring` for secure storage
### Repository Access ### Repository Access
- **Fork workflow**: Use personal forks for changes - **Fork workflow**: Use personal forks for changes
- **Branch isolation**: Each work item gets separate branch - **Branch isolation**: Each task gets separate branch
- **Review process**: All changes go through pull requests - **Review process**: All changes go through pull requests
## Support ## Support
@@ -298,10 +194,8 @@ Include relevant cross-references to related topics.
1. **Check logs** in Processing Log tab for detailed errors 1. **Check logs** in Processing Log tab for detailed errors
2. **Test connections** using Settings dialog test button 2. **Test connections** using Settings dialog test button
3. **Review configuration** for missing or incorrect values 3. **Review configuration** for missing or incorrect values
4. **Consult documentation** for API-specific issues
### Common Resources ### 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) - [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) - [Git Configuration Guide](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup)
-125
View File
@@ -1,125 +0,0 @@
"""
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
@@ -1,33 +0,0 @@
"""
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'
]
@@ -1,213 +0,0 @@
"""
Azure DevOps API Manager
Handles Azure DevOps REST API operations for work items
"""
import base64
import json
import re
import requests
from typing import List, Dict, Any, Tuple
from urllib.parse import urlparse, parse_qs
# User agent for Azure DevOps API requests
USER_AGENT = "azure-devops-github-processor/2.0"
class AzureDevOpsAPI:
"""Azure DevOps REST API client"""
def __init__(self, organization: str, pat_token: str, logger=None):
self.organization = organization
self.pat_token = pat_token
self.logger = logger
self.base_url = f"https://dev.azure.com/{organization}"
self.api_version = "7.0"
def log(self, message: str) -> None:
"""Log a message"""
if self.logger:
self.logger.log(message)
else:
print(message)
def _headers(self):
"""Get headers for Azure DevOps API requests"""
return {
"Authorization": f"Basic {base64.b64encode(f':{self.pat_token}'.encode()).decode()}",
"Content-Type": "application/json-patch+json",
"User-Agent": USER_AGENT
}
def parse_query_url(self, url: str) -> Tuple[str, str, str]:
"""Parse Azure DevOps query URL to extract org, project, and query ID
Supports both URL formats:
1. https://dev.azure.com/organization/project/_queries/query/12345/
2. https://organization.visualstudio.com/project/_queries/query/12345/
"""
parsed_url = urlparse(url)
# Check for dev.azure.com format
if 'dev.azure.com' in parsed_url.netloc:
path_parts = parsed_url.path.strip('/').split('/')
if len(path_parts) < 5:
raise ValueError("Invalid query URL format for dev.azure.com")
organization = path_parts[0]
project = path_parts[1]
# Check for visualstudio.com format
elif 'visualstudio.com' in parsed_url.netloc:
# Extract organization from subdomain (e.g., msft-skilling.visualstudio.com)
hostname_parts = parsed_url.netloc.split('.')
if len(hostname_parts) < 3 or hostname_parts[1] != 'visualstudio':
raise ValueError("Invalid visualstudio.com URL format")
organization = hostname_parts[0]
path_parts = parsed_url.path.strip('/').split('/')
if len(path_parts) < 4:
raise ValueError("Invalid query URL format for visualstudio.com")
project = path_parts[0]
else:
raise ValueError("URL must be from dev.azure.com or visualstudio.com")
# Find query ID in the URL (same logic for both formats)
query_id = None
if '_queries/query/' in url:
# Extract query ID from path
for i, part in enumerate(path_parts):
if part == 'query' and i > 0 and path_parts[i-1] == '_queries':
if i + 1 < len(path_parts):
query_id = path_parts[i + 1]
break
elif 'queryId=' in url:
match = re.search(r'queryId=([^&]+)', url)
if match:
query_id = match.group(1)
if not query_id:
raise ValueError("Could not extract query ID from URL")
return organization, project, query_id
def execute_query(self, org: str, project: str, query_id: str, token: str) -> List[Dict[str, Any]]:
"""Execute Azure DevOps query and return work items"""
# Build API URL for query execution
api_url = f"https://dev.azure.com/{org}/{project}/_apis/wit/wiql/{query_id}?api-version=6.0"
# Prepare headers
auth_string = base64.b64encode(f":{token}".encode()).decode()
headers = {
'Authorization': f'Basic {auth_string}',
'Content-Type': 'application/json',
'User-Agent': USER_AGENT
}
# Execute query
self.log(f"Executing query at: {api_url}")
response = requests.get(api_url, headers=headers, timeout=30)
if response.status_code != 200:
raise RuntimeError(f"Query execution failed: {response.status_code} - {response.text}")
query_result = response.json()
work_item_refs = query_result.get('workItems', [])
if not work_item_refs:
self.log("No work items found in query result")
return []
# Get detailed work item data
work_item_ids = [str(item['id']) for item in work_item_refs]
ids_param = ','.join(work_item_ids)
details_url = f"https://dev.azure.com/{org}/{project}/_apis/wit/workitems?ids={ids_param}&api-version=6.0"
self.log(f"Fetching details for {len(work_item_ids)} work items")
details_response = requests.get(details_url, headers=headers, timeout=30)
if details_response.status_code != 200:
raise RuntimeError(f"Work item details fetch failed: {details_response.status_code}")
return details_response.json().get('value', [])
def _get_work_items_details(self, organization: str, work_item_ids: List[str], pat_token: str) -> List[Dict[str, Any]]:
"""Get detailed information for work items"""
try:
# Build batch request URL
ids_param = ','.join(work_item_ids)
details_url = f"https://dev.azure.com/{organization}/_apis/wit/workitems"
headers = {
"Authorization": f"Basic {self._encode_pat(pat_token)}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": USER_AGENT
}
params = {
"ids": ids_param,
"api-version": self.api_version,
"$expand": "fields"
}
self.log(f"Fetching details for work items: {ids_param}")
response = requests.get(details_url, headers=headers, params=params, timeout=30)
response.raise_for_status()
result = response.json()
work_items = result.get('value', [])
self.log(f"Retrieved details for {len(work_items)} work item(s)")
return work_items
except requests.RequestException as e:
raise Exception(f"Network error fetching work item details: {str(e)}")
except Exception as e:
raise Exception(f"Error fetching work item details: {str(e)}")
def add_github_link_to_work_item(self, work_item_id: str, github_url: str, link_title: str = "GitHub Issue"):
"""Add a GitHub issue/PR link to an Azure DevOps work item"""
self.log(f"Adding GitHub link to work item #{work_item_id}: {github_url}")
url = f"{self.base_url}/_apis/wit/workitems/{work_item_id}?api-version=7.0"
patch_document = [
{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "Hyperlink",
"url": github_url,
"attributes": {
"comment": link_title
}
}
}
]
try:
response = requests.patch(url, headers=self._headers(), json=patch_document, timeout=30)
if response.status_code == 200:
self.log(f"✅ Successfully linked GitHub resource to work item #{work_item_id}")
return True
else:
self.log(f"❌ Failed to link GitHub resource: {response.status_code} - {response.text}")
return False
except Exception as e:
self.log(f"❌ Exception linking GitHub resource: {str(e)}")
return False
def _encode_pat(self, pat_token: str) -> str:
"""Encode PAT token for Basic authentication"""
import base64
# For Azure DevOps, username can be empty, just use :token
credentials = f":{pat_token}"
encoded = base64.b64encode(credentials.encode()).decode()
return encoded
@@ -1,304 +0,0 @@
"""
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
@@ -1,255 +0,0 @@
"""
Dataverse API Manager
Handles PowerApp/Dataverse operations for UUF items
"""
import json
import re
import requests
import urllib.parse
from typing import List, Dict, Any, Optional
from urllib.parse import urlparse
# Constants
USER_AGENT = "azure-devops-github-processor/2.0"
class DataverseAPI:
"""Dataverse/PowerApp API client for UUF items"""
def __init__(self, environment_url: str, table_name: str, logger=None, config: dict = None):
self.environment_url = environment_url.rstrip('/')
self.table_name = table_name
self.logger = logger
self.config = config or {}
self.access_token = None
self.api_version = "v9.2"
def log(self, message: str) -> None:
"""Log a message"""
if self.logger:
self.logger.log(message)
else:
print(message)
def authenticate(self, client_id: str, client_secret: str, tenant_id: str) -> bool:
"""Authenticate with Azure AD and get access token"""
try:
# Azure AD token endpoint
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
# Prepare request data
data = {
'grant_type': 'client_credentials',
'client_id': client_id,
'client_secret': client_secret,
'scope': f"{self.environment_url}/.default"
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded'
}
self.log("Authenticating with Azure AD...")
response = requests.post(token_url, data=data, headers=headers, timeout=30)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data['access_token']
self.log("✅ Successfully authenticated with Azure AD")
return True
except requests.RequestException as e:
self.log(f"❌ Network error during authentication: {str(e)}")
return False
except KeyError as e:
self.log(f"❌ Invalid token response: {str(e)}")
return False
except Exception as e:
self.log(f"❌ Authentication error: {str(e)}")
return False
def _headers(self):
"""Get headers for Dataverse API requests"""
return {
"Authorization": f"Bearer {self.access_token}",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": USER_AGENT
}
def fetch_uuf_items(self, filter_query: Optional[str] = None) -> List[Dict[str, Any]]:
"""Fetch UUF items from Dataverse"""
try:
if not self.access_token:
raise RuntimeError("Not authenticated. Call authenticate() first.")
self.log(f"Fetching UUF items from table: {self.table_name}")
# Build API URL
api_url = f"{self.environment_url}/api/data/{self.api_version}/{self.table_name}"
# Add filter if provided
if filter_query:
api_url += f"?$filter={urllib.parse.quote(filter_query)}"
response = requests.get(api_url, headers=self._headers(), timeout=60)
if response.status_code != 200:
raise RuntimeError(f"Failed to fetch UUF items: {response.status_code} - {response.text}")
data = response.json()
items = data.get('value', [])
self.log(f"✅ Fetched {len(items)} UUF items from Dataverse")
return items
except Exception as e:
self.log(f"❌ Error fetching UUF items: {str(e)}")
raise
def process_uuf_item(self, uuf_item: dict) -> dict | None:
"""Process a single UUF item from Dataverse/PowerApp
UUF items may have different field names than Azure DevOps work items.
Adjust the field mapping based on your actual Dataverse table schema.
"""
try:
# Extract UUF item ID (adjust field names as needed)
uuf_id = uuf_item.get('cr4af_uufid') or uuf_item.get('cr4af_name') or uuf_item.get('cr_uufitemid') or 'unknown'
# Extract title
title = uuf_item.get('cr4af_title') or uuf_item.get('cr4af_subject') or uuf_item.get('cr_title') or 'No Title'
# Extract description/details
description = uuf_item.get('cr4af_description') or uuf_item.get('cr4af_details') or uuf_item.get('cr_description') or ''
if not description:
self.log(f"UUF item {uuf_id} has no description, skipping")
return None
# Extract document URL
doc_url = uuf_item.get('cr4af_documenturl') or uuf_item.get('cr4af_docurl') or uuf_item.get('cr_documenturl') or ''
if not doc_url:
self.log(f"UUF item {uuf_id} has no document URL, skipping")
return None
# Extract text to change and new text
text_to_change = uuf_item.get('cr4af_texttochange') or uuf_item.get('cr4af_currenttext') or uuf_item.get('cr_currenttext') or ''
new_text = uuf_item.get('cr4af_proposednewtext') or uuf_item.get('cr4af_newtext') or uuf_item.get('cr_newtext') or ''
if not text_to_change or not new_text:
self.log(f"UUF item {uuf_id} missing text fields, skipping")
return None
# Extract GitHub info from document URL
github_info = self._extract_github_info(doc_url)
# If the document does not include an original_content_git_url, skip this item
if not github_info.get('original_content_git_url'):
self.log(f"UUF item {uuf_id} skipped: original_content_git_url not found in document {doc_url}")
return None
processed_item = {
'id': uuf_id,
'title': title,
'nature_of_request': 'UUF Item - Modify existing docs',
'mydoc_url': doc_url,
'text_to_change': text_to_change,
'new_text': new_text,
'github_info': github_info,
'status': 'Ready',
'original_new_text': new_text,
'source': 'UUF' # Mark as UUF item
}
self.log(f"Successfully processed UUF item {uuf_id}")
return processed_item
except Exception as e:
self.log(f"Error processing UUF item {uuf_item.get('cr4af_uufid', 'unknown')}: {str(e)}")
return None
def _extract_github_info(self, doc_url: str) -> dict:
"""Extract GitHub repository info and ms.author from document URL
If GITHUB_REPO is configured in .env, it will be used instead of the repo
extracted from the document metadata. This allows you to create PRs in your
fork while preserving the file path and ms.author from the original document.
"""
try:
# Fetch the document
headers = {'User-Agent': USER_AGENT}
response = requests.get(doc_url, headers=headers, timeout=30)
response.raise_for_status()
html = response.text
# Extract ms.author
ms_author = self._extract_meta_tag(html, 'ms.author')
# Extract original_content_git_url
original_content_git_url = self._extract_meta_tag(html, 'original_content_git_url')
if not original_content_git_url:
# Try alternative extraction method
match = re.search(r"original_content_git_url[\"\']?\s*[:=]\s*[\"\']([^\"']+)[\"']", html, re.IGNORECASE)
if match:
original_content_git_url = match.group(1).strip()
if not original_content_git_url:
raise ValueError("original_content_git_url not found in document")
# Check if GITHUB_REPO is configured in .env
# If it is, use that instead of the repo from the document
configured_repo = self.config.get('GITHUB_REPO')
if configured_repo and '/' in configured_repo:
# Use the configured repository (e.g., "b-tsammons/fabric-docs-pr")
parts = configured_repo.split('/', 1)
owner = parts[0].strip()
repo = parts[1].strip()
self.log(f"Using configured GITHUB_REPO: {owner}/{repo} (overriding document metadata)")
else:
# Parse GitHub owner/repo from original_content_git_url (fallback to document metadata)
owner, repo = self._parse_github_url(original_content_git_url)
self.log(f"Using repository from document metadata: {owner}/{repo}")
return {
'ms_author': ms_author,
'original_content_git_url': original_content_git_url,
'owner': owner,
'repo': repo
}
except Exception as e:
self.log(f"Error extracting GitHub info from {doc_url}: {str(e)}")
return {
'ms_author': None,
'original_content_git_url': None,
'owner': None,
'repo': None,
'error': str(e)
}
def _extract_meta_tag(self, html: str, name: str) -> str | None:
"""Extract content from meta tag"""
pattern = rf'<meta\s+(?:[^>]*?\s)?(?:name|property)\s*=\s*["\'](?P<n>{re.escape(name)})["\']\s+[^>]*?\bcontent\s*=\s*["\'](?P<content>[^"\']+)["\'][^>]*?>'
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group('content').strip()
return None
def _parse_github_url(self, url: str) -> tuple[str, str]:
"""Parse GitHub URL to extract owner and repo"""
parsed = urlparse(url)
if "github.com" not in parsed.netloc.lower():
raise ValueError(f"Not a GitHub URL: {url}")
parts = [p for p in parsed.path.split("/") if p]
if len(parts) < 2:
raise ValueError(f"Unable to parse owner/repo from: {url}")
return parts[0], parts[1]
File diff suppressed because it is too large Load Diff
@@ -1,850 +0,0 @@
"""
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
@@ -1,291 +0,0 @@
"""
Work Item Processor
Handles processing of Azure DevOps work items and UUF items
"""
import re
import html
import requests
from typing import Dict, Any, Optional, Tuple
from urllib.parse import urlparse
from .utils import WorkItemFieldExtractor
# User agent for web requests
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
class WorkItemProcessor:
"""Processor for extracting and validating work item data with advanced parsing"""
def __init__(self, logger, config: Dict[str, Any] = None):
self.logger = logger
self.log = logger.log if hasattr(logger, 'log') else logger
self.config = config or {}
def process_work_item(self, work_item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Process a single work item to extract required fields with advanced validation"""
try:
work_item_id = work_item['id']
title = work_item.get('fields', {}).get('System.Title', 'No Title')
description = work_item.get('fields', {}).get('System.Description', '')
if not description:
self.log(f"Work item {work_item_id} has no description, skipping")
return None
# Parse description for required fields
parsed_data = self._parse_description(description)
if not parsed_data:
self.log(f"Work item {work_item_id} doesn't contain required fields, skipping")
return None
# Validate nature of request (check for both variations)
nature_lower = parsed_data['nature_of_request'].lower()
if not ("modify existing docs" in nature_lower or "modifying existing docs" in nature_lower):
self.log(f"Work item {work_item_id} nature of request doesn't contain 'modify existing docs', skipping")
return None
# Extract GitHub info from document URL
github_info = self._extract_github_info(parsed_data['mydoc_url'])
# If the document does not include an original_content_git_url, skip this work item
if not github_info.get('original_content_git_url'):
self.log(f"Work item {work_item_id} skipped: original_content_git_url not found in document {parsed_data['mydoc_url']}")
return None
# Construct proper web URL for work item
# The API returns something like: https://dev.azure.com/org/project/_apis/wit/workItems/123
# We need to convert it to: https://dev.azure.com/org/project/_workitems/edit/123
work_item_url = ''
api_url = work_item.get('url', '')
if api_url:
# Convert API URL to web URL
# Replace /_apis/wit/workItems/ with /_workitems/edit/
work_item_url = api_url.replace('/_apis/wit/workItems/', '/_workitems/edit/')
processed_item = {
'id': work_item_id,
'title': title,
'nature_of_request': parsed_data['nature_of_request'],
'mydoc_url': parsed_data['mydoc_url'],
'text_to_change': parsed_data['text_to_change'],
'new_text': parsed_data['new_text'],
'github_info': github_info,
'status': 'Ready',
'source': 'Azure DevOps',
'source_url': work_item_url, # URL to Azure DevOps work item
'original_new_text': parsed_data['new_text'] # Keep original for reference
}
self.log(f"Successfully processed work item {work_item_id}")
return processed_item
except Exception as e:
self.log(f"Error processing work item {work_item.get('id', 'unknown')}: {str(e)}")
return None
def process_uuf_item(self, uuf_item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Process a single UUF item from Dataverse/PowerApp with enhanced field mapping"""
try:
# Extract UUF item ID (adjust field name as needed)
uuf_id = uuf_item.get('cr4af_uufid') or uuf_item.get('cr4af_name') or 'unknown'
# Extract title
title = uuf_item.get('cr4af_title') or uuf_item.get('cr4af_subject') or 'No Title'
# Extract description/details
description = uuf_item.get('cr4af_description') or uuf_item.get('cr4af_details') or ''
if not description:
self.log(f"UUF item {uuf_id} has no description, skipping")
return None
# Extract document URL
doc_url = uuf_item.get('cr4af_documenturl') or uuf_item.get('cr4af_docurl') or ''
if not doc_url:
self.log(f"UUF item {uuf_id} has no document URL, skipping")
return None
# Extract text to change and new text
text_to_change = uuf_item.get('cr4af_texttochange') or uuf_item.get('cr4af_currenttext') or ''
new_text = uuf_item.get('cr4af_proposednewtext') or uuf_item.get('cr4af_newtext') or ''
if not text_to_change or not new_text:
self.log(f"UUF item {uuf_id} missing text fields, skipping")
return None
# Extract GitHub info from document URL
github_info = self._extract_github_info(doc_url)
# If the document does not include an original_content_git_url, skip this item
if not github_info.get('original_content_git_url'):
self.log(f"UUF item {uuf_id} skipped: original_content_git_url not found in document {doc_url}")
return None
# Get UUF item URL if available (e.g., from Dataverse)
uuf_url = uuf_item.get('cr4af_itemurl', '') or uuf_item.get('cr4af_url', '')
processed_item = {
'id': uuf_id,
'title': title,
'nature_of_request': 'UUF Item - Modify existing docs',
'mydoc_url': doc_url,
'text_to_change': text_to_change,
'new_text': new_text,
'github_info': github_info,
'status': 'Ready',
'source': 'UUF', # Mark as UUF item
'source_url': uuf_url, # URL to UUF item (if available)
'original_new_text': new_text
}
self.log(f"Successfully processed UUF item {uuf_id}")
return processed_item
except Exception as e:
self.log(f"Error processing UUF item {uuf_item.get('cr4af_uufid', 'unknown')}: {str(e)}")
return None
def _parse_description(self, description: str) -> Optional[Dict[str, Any]]:
"""Parse work item description to extract required fields using enhanced regex patterns"""
# Enhanced regex patterns from regex_V5
patterns = {
'nature_of_request': r'nature\s+of\s+request[:\s]*([^\)]*\))',
'link_to_doc': r'link\s+to\s+doc[:\s]*([^\s&]+)',
'text_to_change': r'text\s+to\s+change[:\s]*([\s\S]*?)(?=\n*-+\s*Proposed new text|If adding brand new docs:|$)',
'proposed_new_text': r'proposed\s+new\s+text[:\s]*([\s\S]+?)(?=\s*If\s+adding\s+brand\s+new\s+docs:)'
}
# Clean HTML tags if present
clean_description = re.sub(r'<[^>]+>', '', description)
# Convert HTML entities to characters (e.g., &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
@@ -1,9 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

+46
View File
@@ -0,0 +1,46 @@
[project]
name = "GitHub Pulse"
version = "0.0.1"
description = "A Python-based GUI application for GitHub automation workflows and AI assisted workflows."
readme = "README.md"
requires-python = ">=3.9"
authors = [
{ name = "Flet developer", email = "you@example.com" }
]
dependencies = [
"flet==0.28.3",
"requests>=2.32.5",
"keyring>=25.6.0",
"GitPython>=3.1.45",
"openai>=2.8.0",
"anthropic>=0.72.1"
]
[tool.flet]
# org name in reverse domain name notation, e.g. "com.mycompany".
# Combined with project.name to build bundle ID for iOS and Android apps
org = "com.mycompany"
# project display name that is used as an app title on Android and iOS home screens,
# shown in window titles and about app dialogs on desktop.
product = "GitHub Pulse"
# company name to display in about app dialogs
company = "Flet"
# copyright text to display in about app dialogs
copyright = "Copyright (C) 2025 by Flet"
[tool.flet.app]
path = "src"
[tool.uv]
dev-dependencies = [
"flet[all]==0.28.3",
]
[tool.poetry]
package-mode = false
[tool.poetry.group.dev.dependencies]
flet = {extras = ["all"], version = "0.28.3"}
+54
View File
@@ -0,0 +1,54 @@
"""
GitHub Pulse - Application Components
Modular components for the application
"""
import sys
import os
# Version info
__version__ = "0.0.1"
__author__ = "TySP-Dev"
__app_name__ = "GitHub Pulse"
# Determine if running in production build
IS_PRODUCTION = getattr(sys, 'frozen', False)
# Get the application directory
if IS_PRODUCTION:
# In production build, get the executable directory
APP_DIR = os.path.dirname(sys.executable)
else:
# In development, get the source directory
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Export main classes for easier imports
from .config_manager import ConfigManager
from .ai_manager import AIManager
from .github_api import GitHubAPI
from .settings_dialog import SettingsDialog
from .main_gui import MainGUI
from .utils import Logger, PRNumberManager, ContentBuilders
from .workflow import WorkflowManager, WorkflowItem, GitHubRepoFetcher
from .ai_action_planner import AIActionPlanner, ActionPlan
__all__ = [
'ConfigManager',
'AIManager',
'GitHubAPI',
'SettingsDialog',
'MainGUI',
'Logger',
'PRNumberManager',
'ContentBuilders',
'WorkflowManager',
'WorkflowItem',
'GitHubRepoFetcher',
'AIActionPlanner',
'ActionPlan',
'__version__',
'__author__',
'__app_name__',
'IS_PRODUCTION',
'APP_DIR'
]
+617
View File
@@ -0,0 +1,617 @@
"""
AI Action Planner
Generates and executes action plans for GitHub issues and PRs using AI
"""
import json
import re
import requests
from typing import List, Dict, Any, Optional, Callable
from pathlib import Path
class ActionPlan:
"""Represents an AI-generated action plan"""
def __init__(self, title: str, steps: List[Dict[str, Any]], context: Dict[str, Any]):
self.title = title
self.steps = steps # List of {description, file_path, changes, completed}
self.context = context # PR/Issue context
self.completed_steps = []
self.failed_steps = []
def to_dict(self) -> Dict[str, Any]:
"""Convert plan to dictionary"""
return {
'title': self.title,
'steps': self.steps,
'context': self.context,
'completed_steps': self.completed_steps,
'failed_steps': self.failed_steps
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ActionPlan':
"""Create plan from dictionary"""
plan = cls(data['title'], data['steps'], data['context'])
plan.completed_steps = data.get('completed_steps', [])
plan.failed_steps = data.get('failed_steps', [])
return plan
class OllamaProvider:
"""Simple Ollama API provider for AI action planning"""
def __init__(self, base_url: str, model: str, logger):
self.base_url = base_url.rstrip('/')
self.model = model
self.logger = logger
def generate(self, prompt: str) -> Optional[str]:
"""Generate a response from Ollama"""
try:
response = requests.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"stream": False
},
timeout=120
)
response.raise_for_status()
result = response.json()
return result.get('response', '')
except Exception as e:
self.logger.log(f"❌ Ollama API error: {str(e)}")
return None
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
"""Make changes to file content using Ollama"""
# Try direct replacement first
if old_text and old_text.strip() in file_content:
return file_content.replace(old_text.strip(), new_text.strip())
# Use Ollama to make intelligent changes
prompt = f"""You are a code modification assistant. Modify the following file according to the instructions.
File: {file_path}
Current Content:
```
{file_content}
```
Instructions: {new_text}
{f'Additional context: {custom_instructions}' if custom_instructions else ''}
Return ONLY the complete modified file content. Do not include explanations or markdown code blocks."""
return self.generate(prompt)
class AIActionPlanner:
"""Generates and executes action plans using AI"""
def __init__(self, ai_manager, logger, config_manager):
self.ai_manager = ai_manager
self.logger = logger
self.config_manager = config_manager
def generate_plan(self, item, custom_instructions: str = "") -> Optional[ActionPlan]:
"""
Generate an action plan for a PR or Issue
Args:
item: The PR or Issue (WorkflowItem object or dict)
custom_instructions: Optional user-provided instructions
Returns:
ActionPlan object or None if generation failed
"""
# Handle both WorkflowItem objects and dictionaries
if hasattr(item, 'item_type'):
# It's a WorkflowItem object
item_type = item.item_type
item_number = item.number
title = item.title
body = item.body or ''
repo = getattr(item, 'repo', None)
else:
# It's a dictionary
item_type = item.get('type', 'unknown')
item_number = item.get('number')
title = item.get('title', 'Untitled')
body = item.get('body', '')
repo = item.get('repo')
self.logger.log(f"🤖 Generating action plan for {item_type} #{item_number}...")
# Get AI provider
config = self.config_manager.get_config()
ai_provider_name = config.get('AI_PROVIDER', 'none').lower()
if ai_provider_name == 'none' or not ai_provider_name:
self.logger.log("❌ No AI provider configured. Please configure in Settings.")
return None
# Get provider instance
provider = self._get_ai_provider(ai_provider_name, config)
if not provider:
return None
# Generate the plan using AI
try:
self.logger.log(f"📤 Calling AI provider: {type(provider).__name__}")
plan_text = self._call_ai_for_plan(provider, item_type, title, body, custom_instructions)
if not plan_text:
self.logger.log("❌ AI did not generate a plan (empty response)")
return None
self.logger.log(f"📥 Received response from AI ({len(plan_text)} characters)")
self.logger.log(f"📄 Response preview: {plan_text[:200]}...")
# Parse the plan
self.logger.log("🔍 Parsing AI response into steps...")
steps = self._parse_plan(plan_text)
if not steps:
self.logger.log("❌ Could not parse action steps from AI response")
return None
# Get repo from item or config
if repo is None:
repo = config.get('GITHUB_REPO', '')
plan = ActionPlan(
title=f"Action Plan for {item_type.upper()} #{item_number}: {title}",
steps=steps,
context={
'item_type': item_type,
'item_number': item_number,
'item_title': title,
'item_body': body,
'repo': repo
}
)
self.logger.log(f"✅ Generated plan with {len(steps)} steps")
return plan
except Exception as e:
self.logger.log(f"❌ Error generating plan: {str(e)}")
return None
def _get_ai_provider(self, provider_name: str, config: Dict[str, Any]):
"""Get the AI provider instance"""
try:
if provider_name in ['claude', 'anthropic']:
# Try both CLAUDE_API_KEY and ANTHROPIC_API_KEY for compatibility
api_key = config.get('CLAUDE_API_KEY')
if not api_key:
api_key = config.get('ANTHROPIC_API_KEY')
if not api_key:
self.logger.log("❌ Claude API key not found in secure storage (tried both CLAUDE_API_KEY and ANTHROPIC_API_KEY)")
return None
self.logger.log("️ Initializing Claude provider...")
from . import ai_manager
provider = ai_manager.ClaudeProvider(api_key, self.logger)
self.logger.log("✅ Claude provider initialized successfully")
return provider
elif provider_name in ['chatgpt', 'openai']:
api_key = config.get('OPENAI_API_KEY')
if not api_key:
self.logger.log("❌ OpenAI API key not found in secure storage")
return None
self.logger.log("️ Initializing ChatGPT provider...")
from . import ai_manager
provider = ai_manager.ChatGPTProvider(api_key, self.logger)
self.logger.log("✅ ChatGPT provider initialized successfully")
return provider
elif provider_name == 'ollama':
# Ollama doesn't need an API key, uses URL from config
ollama_url = config.get('OLLAMA_URL', 'http://localhost:11434')
ollama_model = config.get('OLLAMA_MODEL', 'llama2')
self.logger.log(f"️ Using Ollama at {ollama_url} with model {ollama_model}")
# Create a simple Ollama provider wrapper
return OllamaProvider(ollama_url, ollama_model, self.logger)
else:
self.logger.log(f"❌ Unsupported AI provider: {provider_name}")
return None
except Exception as e:
self.logger.log(f"❌ Error creating AI provider: {str(e)}")
return None
def _call_ai_for_plan(self, provider, item_type: str, title: str, body: str, custom_instructions: str) -> Optional[str]:
"""Call AI to generate an action plan"""
prompt = f"""You are an expert software engineer tasked with creating an actionable plan to address a GitHub {item_type}.
{item_type.upper()} Title: {title}
{item_type.upper()} Description:
{body}
{"Additional Instructions: " + custom_instructions if custom_instructions else ""}
Please create a detailed action plan with specific, executable steps. For each step, specify:
1. What needs to be done (clear description)
2. Which file(s) need to be modified (if applicable)
3. What changes should be made (if applicable)
Format your response as a JSON array of steps, where each step has:
- "description": A clear description of what to do
- "file_path": Path to the file to modify (or null if not file-specific)
- "changes": Description of changes to make (or null if not applicable)
- "action_type": One of ["modify_file", "create_file", "delete_file", "investigate", "test", "document"]
Example format:
```json
[
{{
"description": "Fix the authentication bug in login handler",
"file_path": "src/auth/login.py",
"changes": "Update the password validation logic to handle special characters correctly",
"action_type": "modify_file"
}},
{{
"description": "Add unit tests for authentication",
"file_path": "tests/test_auth.py",
"changes": "Add test cases for special characters in passwords",
"action_type": "create_file"
}}
]
```
IMPORTANT: Return ONLY the JSON array, no other text before or after."""
try:
if isinstance(provider, OllamaProvider):
# Use Ollama
self.logger.log(f"🤖 Calling Ollama AI to generate plan...")
return provider.generate(prompt)
elif hasattr(provider, '_generate_updated_document'):
# Use Claude's document generation
self.logger.log(f"🤖 Calling Claude AI to generate plan...")
import anthropic
client = anthropic.Anthropic(api_key=provider.api_key)
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
messages=[{"role": "user", "content": prompt}]
)
return message.content[0].text
elif hasattr(provider, 'client'):
# Use OpenAI/ChatGPT
self.logger.log(f"🤖 Calling ChatGPT AI to generate plan...")
response = provider.client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
max_tokens=4096
)
self.logger.log(f"✅ ChatGPT response received")
return response.choices[0].message.content
else:
self.logger.log(f"❌ Unknown provider type: {type(provider).__name__}")
return None
except Exception as e:
self.logger.log(f"❌ AI API call failed: {str(e)}")
import traceback
self.logger.log(f"❌ Traceback: {traceback.format_exc()}")
return None
def _parse_plan(self, plan_text: str) -> List[Dict[str, Any]]:
"""Parse the AI-generated plan text into structured steps"""
try:
# Extract JSON from response (might be wrapped in markdown)
json_match = re.search(r'```json\s*(\[.*?\])\s*```', plan_text, re.DOTALL)
if json_match:
json_text = json_match.group(1)
else:
# Try to find JSON array directly
json_match = re.search(r'\[.*\]', plan_text, re.DOTALL)
if json_match:
json_text = json_match.group(0)
else:
self.logger.log("⚠️ Could not find JSON in AI response")
return []
# Parse JSON
steps = json.loads(json_text)
# Validate and clean up steps
validated_steps = []
for i, step in enumerate(steps):
if isinstance(step, dict):
validated_step = {
'step_number': i + 1,
'description': step.get('description', f'Step {i+1}'),
'file_path': step.get('file_path'),
'changes': step.get('changes'),
'action_type': step.get('action_type', 'investigate'),
'completed': False,
'status': 'pending'
}
validated_steps.append(validated_step)
self.logger.log(f"✅ Successfully parsed {len(validated_steps)} steps from AI response")
return validated_steps
except json.JSONDecodeError as e:
self.logger.log(f"❌ Failed to parse JSON: {str(e)}")
self.logger.log(f"Response was: {plan_text[:500]}...")
return []
except Exception as e:
self.logger.log(f"❌ Error parsing plan: {str(e)}")
return []
def execute_plan(
self,
plan: ActionPlan,
local_repo_path: str,
progress_callback: Optional[Callable[[int, int, str], None]] = None,
log_callback: Optional[Callable[[str], None]] = None
) -> Dict[str, Any]:
"""
Execute an action plan
Args:
plan: The ActionPlan to execute
local_repo_path: Path to local git repository
progress_callback: Callback function(current_step, total_steps, message)
log_callback: Callback function for logging thought process
Returns:
Dictionary with execution results
"""
def log(message):
"""Helper to log to both logger and callback"""
self.logger.log(message)
if log_callback:
log_callback(message)
log(f"▶️ Starting execution of plan: {plan.title}")
if not local_repo_path or not Path(local_repo_path).exists():
log(f"❌ Local repository path not found: {local_repo_path}")
return {'success': False, 'error': 'Invalid local repository path'}
total_steps = len(plan.steps)
completed = 0
failed = 0
for i, step in enumerate(plan.steps):
step_num = step['step_number']
# Mark step as in-progress
step['status'] = 'in_progress'
if progress_callback:
progress_callback(i + 1, total_steps, f"Executing step {step_num}...")
log(f"\n📍 Step {step_num}/{total_steps}: {step['description']}")
try:
result = self._execute_step(step, local_repo_path, plan.context, log)
if result['success']:
step['completed'] = True
step['status'] = 'completed'
plan.completed_steps.append(step_num)
completed += 1
log(f"✅ Step {step_num} completed")
else:
step['status'] = 'failed'
step['error'] = result.get('error', 'Unknown error')
plan.failed_steps.append(step_num)
failed += 1
log(f"❌ Step {step_num} failed: {result.get('error')}")
except Exception as e:
step['status'] = 'failed'
step['error'] = str(e)
plan.failed_steps.append(step_num)
failed += 1
log(f"❌ Step {step_num} failed with exception: {str(e)}")
log(f"\n📊 Execution complete: {completed}/{total_steps} steps successful, {failed} failed")
# If we made changes successfully, commit and push them
if completed > 0:
try:
log("\n🔧 Committing and pushing changes...")
# Get PR/Issue info from context
item_type = plan.context.get('item_type', 'item')
item_number = plan.context.get('item_number', 'unknown')
item_title = plan.context.get('item_title', 'changes')
# Commit message
commit_msg = f"AI: Execute action plan for {item_type} #{item_number}\n\n{item_title}\n\nAutomated changes by GitHub Pulse AI"
# Get current branch (should be the PR branch)
import subprocess
result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
cwd=local_repo_path,
capture_output=True,
text=True,
timeout=10
)
current_branch = result.stdout.strip() if result.returncode == 0 else 'main'
log(f"📍 Current branch: {current_branch}")
# Stage all changes
log("📝 Staging changes...")
subprocess.run(['git', 'add', '-A'], cwd=local_repo_path, check=True, timeout=10)
# Check if there are changes to commit
result = subprocess.run(
['git', 'diff', '--cached', '--quiet'],
cwd=local_repo_path,
timeout=10
)
if result.returncode != 0: # There are changes
# Commit
log("💾 Committing changes...")
subprocess.run(
['git', 'commit', '-m', commit_msg],
cwd=local_repo_path,
check=True,
timeout=10
)
# Push
log(f"🚀 Pushing to {current_branch}...")
subprocess.run(
['git', 'push', 'origin', current_branch],
cwd=local_repo_path,
check=True,
timeout=30
)
log(f"✅ Changes pushed to {current_branch}")
else:
log("️ No changes to commit")
except subprocess.TimeoutExpired:
log("⚠️ Git operation timed out")
except subprocess.CalledProcessError as e:
log(f"⚠️ Git operation failed: {e}")
except Exception as e:
log(f"⚠️ Error during git commit/push: {str(e)}")
return {
'success': failed == 0,
'completed': completed,
'failed': failed,
'total': total_steps,
'plan': plan
}
def _execute_step(self, step: Dict[str, Any], local_repo_path: str, context: Dict[str, Any], log=None) -> Dict[str, Any]:
"""Execute a single step of the plan"""
action_type = step.get('action_type', 'investigate')
file_path = step.get('file_path')
changes = step.get('changes')
# Use log function if provided, otherwise fall back to logger
log_func = log if log else self.logger.log
if action_type == 'modify_file' and file_path:
return self._modify_file(file_path, changes, local_repo_path, log_func)
elif action_type == 'create_file' and file_path:
return self._create_file(file_path, changes, local_repo_path, log_func)
elif action_type == 'delete_file' and file_path:
return self._delete_file(file_path, local_repo_path, log_func)
else:
# For investigate, test, document actions, just mark as completed
# (requires manual intervention)
log_func(f"️ Manual action required: {step['description']}")
return {'success': True, 'message': 'Manual action logged'}
def _modify_file(self, file_path: str, changes: str, local_repo_path: str, log=None) -> Dict[str, Any]:
"""Modify a file using AI"""
log_func = log if log else self.logger.log
full_path = Path(local_repo_path) / file_path
if not full_path.exists():
return {'success': False, 'error': f'File not found: {file_path}'}
try:
log_func(f"📝 Reading file: {file_path}")
# Read current content
with open(full_path, 'r', encoding='utf-8') as f:
current_content = f.read()
# Get AI provider to make changes
config = self.config_manager.get_config()
provider_name = config.get('AI_PROVIDER', 'none').lower()
provider = self._get_ai_provider(provider_name, config)
if not provider:
return {'success': False, 'error': 'AI provider not available'}
# Use AI to make the changes
log_func(f"🤖 Using AI to modify {file_path}...")
log_func(f"🔍 Analyzing changes needed...")
updated_content = provider.make_change(
file_content=current_content,
old_text=current_content[:200] + "...", # Context
new_text=changes, # What to change
file_path=str(full_path),
custom_instructions=changes
)
if updated_content and updated_content != current_content:
log_func(f"💾 Writing changes to {file_path}...")
# Write updated content
with open(full_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
log_func(f"✅ Successfully modified {file_path}")
return {'success': True, 'file': file_path}
else:
return {'success': False, 'error': 'AI could not generate changes'}
except Exception as e:
return {'success': False, 'error': f'Error modifying file: {str(e)}'}
def _create_file(self, file_path: str, content: str, local_repo_path: str, log=None) -> Dict[str, Any]:
"""Create a new file"""
log_func = log if log else self.logger.log
full_path = Path(local_repo_path) / file_path
if full_path.exists():
return {'success': False, 'error': f'File already exists: {file_path}'}
try:
log_func(f"📄 Creating new file: {file_path}")
# Create parent directories if needed
full_path.parent.mkdir(parents=True, exist_ok=True)
# Create file with content
with open(full_path, 'w', encoding='utf-8') as f:
f.write(content or f"# TODO: Implement {file_path}\n")
log_func(f"✅ Created {file_path}")
return {'success': True, 'file': file_path}
except Exception as e:
return {'success': False, 'error': f'Error creating file: {str(e)}'}
def _delete_file(self, file_path: str, local_repo_path: str, log=None) -> Dict[str, Any]:
"""Delete a file"""
log_func = log if log else self.logger.log
full_path = Path(local_repo_path) / file_path
if not full_path.exists():
return {'success': False, 'error': f'File not found: {file_path}'}
try:
log_func(f"🗑️ Deleting file: {file_path}")
full_path.unlink()
log_func(f"✅ Deleted {file_path}")
return {'success': True, 'file': file_path}
except Exception as e:
return {'success': False, 'error': f'Error deleting file: {str(e)}'}
@@ -10,10 +10,8 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
import tkinter as tk
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from tkinter import messagebox
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
@@ -744,6 +742,12 @@ Current file content:
class ChatGPTProvider(AIProvider): class ChatGPTProvider(AIProvider):
"""ChatGPT/GPT-4 provider using OpenAI API""" """ChatGPT/GPT-4 provider using OpenAI API"""
def __init__(self, api_key: str, logger: Logger):
"""Initialize ChatGPT provider with OpenAI client"""
super().__init__(api_key, logger)
import openai
self.client = openai.OpenAI(api_key=api_key)
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
"""Make smart, targeted changes based on reference text and suggestions """Make smart, targeted changes based on reference text and suggestions
@@ -776,8 +780,8 @@ class ChatGPTProvider(AIProvider):
"""Generate updated document content using ChatGPT""" """Generate updated document content using ChatGPT"""
try: try:
import openai # Use the client initialized in __init__
client = openai.OpenAI(api_key=self.api_key) client = self.client
# Build custom instructions text # Build custom instructions text
if custom_instructions and custom_instructions.strip(): if custom_instructions and custom_instructions.strip():
@@ -1479,6 +1483,9 @@ Current file content:
class GitHubCopilotProvider(AIProvider): class GitHubCopilotProvider(AIProvider):
"""GitHub Copilot provider using GitHub Models API""" """GitHub Copilot provider using GitHub Models API"""
# GitHub Models API endpoint
GITHUB_MODELS_API_URL = "https://models.inference.ai.azure.com/chat/completions"
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]: def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
"""Use diff-based approach for surgical edits""" """Use diff-based approach for surgical edits"""
@@ -1505,7 +1512,7 @@ class GitHubCopilotProvider(AIProvider):
try: try:
import requests import requests
url = "https://models.inference.ai.azure.com/chat/completions" url = self.GITHUB_MODELS_API_URL
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -1869,7 +1876,7 @@ Return the complete updated file content now (NO explanatory text):"""
try: try:
import requests import requests
url = "https://models.inference.ai.azure.com/chat/completions" url = self.GITHUB_MODELS_API_URL
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -1941,7 +1948,7 @@ Generate the new content now:"""
try: try:
import requests import requests
url = "https://models.inference.ai.azure.com/chat/completions" url = self.GITHUB_MODELS_API_URL
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -2018,7 +2025,7 @@ Find the exact text to correct:"""
try: try:
import requests import requests
url = "https://models.inference.ai.azure.com/chat/completions" url = self.GITHUB_MODELS_API_URL
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -2709,7 +2716,7 @@ class LocalGitManager:
self.logger.log(f"❌ Error saving diff to file: {str(e)}") self.logger.log(f"❌ Error saving diff to file: {str(e)}")
def create_ai_provider(provider_name: str, api_key: str, logger: Logger) -> Optional[AIProvider]: def create_ai_provider(provider_name: str, api_key: str, logger: Logger, ollama_url: str = None, ollama_model: str = None) -> Optional[AIProvider]:
"""Factory function to create AI provider instances""" """Factory function to create AI provider instances"""
if provider_name.lower() == 'claude': if provider_name.lower() == 'claude':
return ClaudeProvider(api_key, logger) return ClaudeProvider(api_key, logger)
@@ -2717,6 +2724,9 @@ def create_ai_provider(provider_name: str, api_key: str, logger: Logger) -> Opti
return ChatGPTProvider(api_key, logger) return ChatGPTProvider(api_key, logger)
elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']: elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']:
return GitHubCopilotProvider(api_key, logger) return GitHubCopilotProvider(api_key, logger)
elif provider_name.lower() == 'ollama':
# For Ollama, api_key is optional (can be empty string)
return OllamaProvider(api_key or "", logger, ollama_url, ollama_model)
else: else:
logger.log(f"⚠️ Unknown AI provider: {provider_name}") logger.log(f"⚠️ Unknown AI provider: {provider_name}")
return None return None
@@ -2756,10 +2766,6 @@ def get_detailed_python_environment_info() -> dict:
def install_ai_packages_enhanced(packages: List[str], parent_window=None) -> bool: def install_ai_packages_enhanced(packages: List[str], parent_window=None) -> bool:
"""Enhanced AI provider package installation with better error handling """Enhanced AI provider package installation with better error handling
Args:
packages: List of package names to install
parent_window: Parent tkinter window for dialog (optional)
Returns: Returns:
bool: True if installation successful or user declined, False if failed bool: True if installation successful or user declined, False if failed
""" """
@@ -2796,101 +2802,10 @@ def install_ai_packages_enhanced(packages: List[str], parent_window=None) -> boo
f"Would you like to install them now?\n\n" f"Would you like to install them now?\n\n"
f"This will run: pip install {' '.join(packages)}") f"This will run: pip install {' '.join(packages)}")
# Show confirmation dialog
try:
import tkinter as tk
from tkinter import messagebox
# If we have a parent window, use it; otherwise create a temporary root
if parent_window:
result = messagebox.askyesno("Install AI Packages", message, parent=parent_window)
else:
# Create temporary root window for the dialog
temp_root = tk.Tk()
temp_root.withdraw() # Hide the temporary window
result = messagebox.askyesno("Install AI Packages", message)
temp_root.destroy()
if not result:
print("User declined to install AI packages")
return True # User declined, but this isn't a failure
except Exception as e:
print(f"Could not show dialog, proceeding with installation: {e}")
# If dialog fails, ask in console
response = input(f"Install AI packages ({package_list})? [y/N]: ").lower()
if response not in ['y', 'yes']:
return True
# Install packages
try:
if in_venv:
print(f"Installing packages to virtual environment: {package_list}")
else:
print(f"Installing packages system-wide: {package_list}")
for package in packages:
print(f"Installing {package}...")
# Build pip command
pip_cmd = [sys.executable, '-m', 'pip', 'install', package]
# First attempt: Direct installation
result = subprocess.run(pip_cmd, capture_output=True, text=True, timeout=300)
# If direct install fails and we're not in venv, try with --user flag
if result.returncode != 0 and not in_venv:
print(f" Direct installation failed, trying with --user flag...")
pip_cmd_user = [sys.executable, '-m', 'pip', 'install', '--user', package]
result = subprocess.run(pip_cmd_user, capture_output=True, text=True, timeout=300)
if result.returncode == 0:
print(f"✅ Successfully installed {package} (user-local)")
continue
if result.returncode != 0:
print(f"❌ Failed to install {package}:")
print(f"Error: {result.stderr}")
# Show more helpful error message
if "permission" in result.stderr.lower() or "access" in result.stderr.lower():
print(" This appears to be a permissions issue.")
if not in_venv:
print(" Consider:")
print(" 1. Running as administrator")
print(" 2. Using a virtual environment")
print(" 3. Installing with --user flag")
return False
else:
install_type = "to virtual environment" if in_venv else "system-wide"
print(f"✅ Successfully installed {package} ({install_type})")
success_msg = "✅ AI packages installed successfully!"
if in_venv:
success_msg += f" (installed to virtual environment)"
else:
success_msg += f" (installed system-wide)"
print(success_msg)
print("Please restart the application to use the new AI features.")
return True
except subprocess.TimeoutExpired:
print("❌ Installation timed out")
return False
except Exception as e:
print(f"❌ Error installing packages: {e}")
return False
def validate_ai_provider_setup(config: dict, parent_window=None) -> bool: def validate_ai_provider_setup(config: dict, parent_window=None) -> bool:
"""Validate AI provider setup and offer to install missing modules """Validate AI provider setup and offer to install missing modules
Args:
config: Configuration dictionary
parent_window: Parent tkinter window for dialogs
Returns: Returns:
bool: True if setup is valid or user handled the issue bool: True if setup is valid or user handled the issue
""" """
@@ -2927,6 +2842,198 @@ def validate_ai_provider_setup(config: dict, parent_window=None) -> bool:
return False return False
class OllamaProvider(AIProvider):
"""Ollama AI provider for self-hosted models"""
def __init__(self, api_key: str, logger: Logger, ollama_url: str = None, model: str = None):
super().__init__(api_key, logger)
self.ollama_url = ollama_url or "http://localhost:11434"
self.model = model or "llama2"
# Normalize URL
if not self.ollama_url.startswith('http'):
self.ollama_url = f"http://{self.ollama_url}"
def make_change(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
"""Make targeted changes using Ollama"""
# Step 1: Try direct string replacement first
if old_text and old_text.strip() in file_content:
self.logger.log("✅ Making direct string replacement (reference text found exactly)")
updated_content = file_content.replace(old_text.strip(), new_text.strip())
if updated_content != file_content:
original_lines = file_content.split('\n')
updated_lines = updated_content.split('\n')
import difflib
diff = list(difflib.unified_diff(original_lines, updated_lines, lineterm=''))
changed_lines = len([line for line in diff if line.startswith('+') or line.startswith('-')])
self.logger.log(f"✅ Direct replacement successful ({changed_lines} lines changed)")
return updated_content
# Step 2: Use Ollama to generate full document with targeted changes
self.logger.log(f"📝 Using Ollama ({self.model}) to modify the document...")
return self._generate_updated_document(file_content, old_text, new_text, file_path, custom_instructions)
def _generate_updated_document(self, file_content: str, old_text: str, new_text: str, file_path: str, custom_instructions: str = None) -> Optional[str]:
"""Generate updated document content using Ollama"""
try:
import requests
# Build custom instructions text
if custom_instructions and custom_instructions.strip():
custom_instructions_text = f"""
**Additional Custom Instructions:**
{custom_instructions.strip()}
"""
else:
custom_instructions_text = ""
# Handle case where new_text is empty or just guidance
if new_text and new_text.strip() and not new_text.strip().lower().startswith('<blank'):
# We have specific replacement text
guidance_text = f"""
**Reference text to find:**
```
{old_text}
```
**Replace with this specific content:**
```
{new_text}
```
Please find the reference text and replace it with the suggested content."""
else:
# new_text is empty or just guidance - use old_text as instructions
guidance_text = f"""
**Task Instructions:**
{old_text}
**Note:** No specific replacement text provided. Use the task instructions above to determine what changes to make to improve the document. Add appropriate content based on the instructions."""
prompt = f"""**Instructions:**
Task: Update the documentation file with the changes requested.
Steps to complete:
1. Review the current file content below
2. Follow the guidance provided to determine what changes to make
3. Make appropriate improvements while maintaining existing formatting
4. Return the complete updated file content
> [!IMPORTANT]
> OUTPUT REQUIREMENTS:
> - Return ONLY the complete file content - no explanatory text, dialog, or commentary
> - Do NOT add any text before or after the file content
> - Do NOT wrap output in markdown code blocks (```), just return the raw content
> - Return the ENTIRE document - no truncation, no placeholders like [Rest of the document here...]
> - Every single line of the original document must be present in your response
> - Preserve all markdown formatting, links, and code blocks exactly
> - Only make changes that fulfill the specified request
{custom_instructions_text}
**Current File Content:**
```
{file_content}
```
{guidance_text}
Return the complete updated file content now (NO explanatory text):"""
# Prepare request headers
headers = {
"Content-Type": "application/json"
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
# Prepare request payload
payload = {
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.3, # Lower temperature for more consistent output
"num_predict": -1, # Generate as many tokens as needed
}
}
# Make request to Ollama
self.logger.log(f"🔄 Sending request to Ollama at {self.ollama_url}...")
response = requests.post(
f"{self.ollama_url}/api/generate",
json=payload,
headers=headers,
timeout=300 # 5 minute timeout for large documents
)
response.raise_for_status()
result = response.json()
updated_content = result.get("response", "").strip()
if not updated_content:
self.logger.log("❌ Ollama returned empty response")
return None
# Clean up response
updated_content = self._clean_ai_response(updated_content)
# Validate that we got the full document back
original_line_count = len(file_content.split('\n'))
updated_line_count = len(updated_content.split('\n'))
if updated_line_count < original_line_count * 0.5: # Less than 50% of original lines
self.logger.log(f"⚠️ Warning: Updated document seems truncated ({updated_line_count} vs {original_line_count} lines)")
self.logger.log("❌ AI may have truncated the document - using fallback")
return None
self.logger.log(f"✅ Successfully generated updated document ({updated_line_count} lines)")
return updated_content
except requests.exceptions.ConnectionError:
self.logger.log(f"❌ Could not connect to Ollama server at {self.ollama_url}")
self.logger.log(" Make sure Ollama is running and the URL is correct")
return None
except requests.exceptions.Timeout:
self.logger.log("❌ Request to Ollama server timed out")
return None
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
self.logger.log("❌ Authentication failed - check your Ollama API key")
elif e.response.status_code == 404:
self.logger.log(f"❌ Model '{self.model}' not found on Ollama server")
self.logger.log(f" Use 'ollama pull {self.model}' to download it")
else:
self.logger.log(f"❌ HTTP error from Ollama: {e}")
return None
except Exception as e:
self.logger.log(f"❌ Error calling Ollama: {str(e)}")
import traceback
traceback.print_exc()
return None
def _clean_ai_response(self, response: str) -> str:
"""Clean up AI response by removing markdown code blocks and explanatory text"""
# Remove markdown code blocks if present
if response.startswith('```'):
lines = response.split('\n')
# Remove first line if it's a code fence
if lines[0].startswith('```'):
lines = lines[1:]
# Remove last line if it's a code fence
if lines and lines[-1].strip() == '```':
lines = lines[:-1]
response = '\n'.join(lines)
return response.strip()
# AI Providers availability flag - now always True since they're included # AI Providers availability flag - now always True since they're included
AI_PROVIDERS_AVAILABLE = True AI_PROVIDERS_AVAILABLE = True
@@ -2954,7 +3061,7 @@ class AIManager:
"""Check if AI provider modules are available and return missing packages """Check if AI provider modules are available and return missing packages
Args: Args:
provider_name: 'chatgpt', 'claude', 'anthropic', or 'github-copilot' provider_name: 'chatgpt', 'claude', 'anthropic', 'github-copilot', or 'ollama'
Returns: Returns:
tuple: (all_available, missing_packages) tuple: (all_available, missing_packages)
@@ -2971,6 +3078,8 @@ class AIManager:
required_packages = required_common + ['anthropic'] required_packages = required_common + ['anthropic']
elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']: elif provider_name.lower() in ['github-copilot', 'copilot', 'github_copilot']:
required_packages = required_common + ['requests'] required_packages = required_common + ['requests']
elif provider_name.lower() == 'ollama':
required_packages = required_common + ['requests']
else: else:
return True, [] # Unknown provider, assume no check needed return True, [] # Unknown provider, assume no check needed
@@ -2982,6 +3091,8 @@ class AIManager:
import openai import openai
elif package == 'anthropic': elif package == 'anthropic':
import anthropic import anthropic
elif package == 'requests':
import requests
except ImportError: except ImportError:
missing_packages.append(package) missing_packages.append(package)
@@ -3073,6 +3184,26 @@ class AIManager:
return self.install_ai_packages(missing, parent_window) return self.install_ai_packages(missing, parent_window)
async def check_and_install_ai_modules_async(self, provider_name: str, page=None) -> bool:
"""Async wrapper for check_and_install_ai_modules for Flet integration
Args:
provider_name: AI provider name
page: Flet page instance for showing dialogs
Returns:
bool: True if modules are available or successfully installed
"""
import asyncio
# Run the sync method in a thread pool
result = await asyncio.to_thread(
self.check_and_install_ai_modules,
provider_name,
page
)
return result
def show_ai_modules_info(self, provider_name: str, parent_window=None) -> None: def show_ai_modules_info(self, provider_name: str, parent_window=None) -> None:
"""Show detailed AI modules information""" """Show detailed AI modules information"""
try: try:
@@ -3159,14 +3290,14 @@ class AIManager:
if self.logger: if self.logger:
self.logger.log(f"Error in AI modules check: {str(e)}") self.logger.log(f"Error in AI modules check: {str(e)}")
def create_ai_provider(self, provider_name: str, api_key: str): def create_ai_provider(self, provider_name: str, api_key: str, ollama_url: str = None, ollama_model: str = None):
"""Create an AI provider instance""" """Create an AI provider instance"""
if not AI_PROVIDERS_AVAILABLE: if not AI_PROVIDERS_AVAILABLE:
return None return None
try: try:
ai_logger = Logger(self.log) ai_logger = Logger(self.log)
return create_ai_provider(provider_name, api_key, ai_logger) return create_ai_provider(provider_name, api_key, ai_logger, ollama_url, ollama_model)
except Exception as e: except Exception as e:
self.log(f"Error creating AI provider: {e}") self.log(f"Error creating AI provider: {e}")
return None return None
@@ -3190,3 +3321,110 @@ class AIManager:
def clear_diff_content(self): def clear_diff_content(self):
"""Clear the stored diff content""" """Clear the stored diff content"""
self.last_diff_content = "" self.last_diff_content = ""
def generate_response(self, prompt: str, provider_name: str, config: dict) -> str:
"""Generate a text response from an AI provider
Args:
prompt: The prompt/question to send to the AI
provider_name: Name of the AI provider ('chatgpt', 'claude', 'ollama', etc.)
config: Configuration dictionary containing API keys and settings
Returns:
str: The AI-generated response
"""
try:
provider_name = provider_name.lower()
# OpenAI/ChatGPT
if provider_name in ['chatgpt', 'openai', 'gpt']:
api_key = config.get('OPENAI_API_KEY', '')
if not api_key:
return "Error: OpenAI API key not configured"
try:
import openai
client = openai.OpenAI(api_key=api_key)
response = client.chat.completions.create(
model=config.get('OPENAI_MODEL', 'gpt-4'),
messages=[
{"role": "system", "content": "You are a helpful assistant that analyzes GitHub pull requests and issues."},
{"role": "user", "content": prompt}
],
max_tokens=2000,
temperature=0.7
)
return response.choices[0].message.content.strip()
except Exception as e:
self.log(f"Error calling OpenAI API: {e}")
return f"Error calling OpenAI API: {str(e)}"
# Anthropic/Claude
elif provider_name in ['claude', 'anthropic']:
# Try both CLAUDE_API_KEY and ANTHROPIC_API_KEY for compatibility
api_key = config.get('CLAUDE_API_KEY', '')
if not api_key:
api_key = config.get('ANTHROPIC_API_KEY', '')
if not api_key:
return "Error: Claude API key not configured (tried both CLAUDE_API_KEY and ANTHROPIC_API_KEY)"
try:
import anthropic
client = anthropic.Anthropic(api_key=api_key)
response = client.messages.create(
model=config.get('ANTHROPIC_MODEL', 'claude-sonnet-4-5'),
max_tokens=2000,
messages=[
{"role": "user", "content": prompt}
]
)
return response.content[0].text.strip()
except Exception as e:
self.log(f"Error calling Anthropic API: {e}")
return f"Error calling Anthropic API: {str(e)}"
# Ollama
elif provider_name == 'ollama':
ollama_url = config.get('OLLAMA_URL', 'http://localhost:11434')
ollama_model = config.get('OLLAMA_MODEL', 'llama2')
try:
import requests
# Normalize URL
if not ollama_url.startswith('http'):
ollama_url = f"http://{ollama_url}"
# Remove trailing slash
ollama_url = ollama_url.rstrip('/')
api_url = f"{ollama_url}/api/generate"
payload = {
"model": ollama_model,
"prompt": prompt,
"stream": False
}
response = requests.post(api_url, json=payload, timeout=120)
response.raise_for_status()
result = response.json()
return result.get('response', '').strip()
except Exception as e:
self.log(f"Error calling Ollama API: {e}")
return f"Error calling Ollama API: {str(e)}"
else:
return f"Error: Unknown AI provider '{provider_name}'"
except Exception as e:
self.log(f"Error in generate_response: {e}")
return f"Error generating response: {str(e)}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,5 +1,5 @@
""" """
Cache Manager for Work Items and UUF Items Cache Manager for GitHub PRs and Issues
Stores fetched items in temporary cache to avoid reloading on every app start Stores fetched items in temporary cache to avoid reloading on every app start
""" """
@@ -13,7 +13,7 @@ from hashlib import md5
class CacheManager: class CacheManager:
"""Manages caching of work items and UUF items""" """Manages caching of GitHub PRs and Issues"""
def __init__(self, cache_duration_hours: int = 24): def __init__(self, cache_duration_hours: int = 24):
""" """
@@ -23,7 +23,7 @@ class CacheManager:
cache_duration_hours: How long cache is valid (default 24 hours) cache_duration_hours: How long cache is valid (default 24 hours)
""" """
self.cache_duration_seconds = cache_duration_hours * 3600 self.cache_duration_seconds = cache_duration_hours * 3600
self.cache_dir = Path(tempfile.gettempdir()) / "devops_to_github_cache" self.cache_dir = Path(tempfile.gettempdir()) / "github_pulse_cache"
self.cache_dir.mkdir(exist_ok=True) self.cache_dir.mkdir(exist_ok=True)
def _get_cache_key(self, source_type: str, identifier: str) -> str: def _get_cache_key(self, source_type: str, identifier: str) -> str:
@@ -50,14 +50,14 @@ class CacheManager:
def load_from_cache(self, source_type: str, identifier: str) -> Optional[List[Dict[str, Any]]]: def load_from_cache(self, source_type: str, identifier: str) -> Optional[List[Dict[str, Any]]]:
""" """
Load work items from cache Load GitHub items from cache
Args: Args:
source_type: 'azure_devops' or 'uuf' source_type: 'github_prs', 'github_issues', 'target_prs', 'fork_prs', etc.
identifier: query URL hash or config hash identifier: repository identifier or config hash
Returns: Returns:
List of work items if cache is valid, None otherwise List of items if cache is valid, None otherwise
""" """
if not self.is_cache_valid(source_type, identifier): if not self.is_cache_valid(source_type, identifier):
return None return None
@@ -81,12 +81,12 @@ class CacheManager:
def save_to_cache(self, source_type: str, identifier: str, items: List[Dict[str, Any]]) -> bool: def save_to_cache(self, source_type: str, identifier: str, items: List[Dict[str, Any]]) -> bool:
""" """
Save work items to cache Save GitHub items to cache
Args: Args:
source_type: 'azure_devops' or 'uuf' source_type: 'github_prs', 'github_issues', 'target_prs', 'fork_prs', etc.
identifier: query URL hash or config hash identifier: repository identifier or config hash
items: List of work items to cache items: List of items to cache (PRs or Issues)
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
+246
View File
@@ -0,0 +1,246 @@
"""
Configuration Manager
Wrapper around SettingsManager for backward compatibility.
Now uses config.json + keyring instead of .env files.
"""
import os
import json
from typing import Dict, Any, Optional
from pathlib import Path
from .settings_manager import SettingsManager
class ConfigManager:
"""
Manages application configuration using the new SettingsManager.
Provides backward compatibility with old .env-based code while
using the modern config.json + keyring system underneath.
"""
def __init__(self):
"""Initialize with SettingsManager backend"""
# Initialize the modern settings system
self._settings = SettingsManager()
# Check if .env exists and offer migration
env_path = Path('.env')
if env_path.exists() and not Path('application/config.json').exists():
print("\n" + "="*60)
print("NOTICE: Legacy .env file detected!")
print("="*60)
print("Your app now uses a modern settings system with:")
print(" ✓ Secure API key storage (Windows Credential Manager)")
print(" ✓ Live settings updates (no restart needed)")
print(" ✓ Better configuration management")
print()
print("Migrating settings from .env to new system...")
print()
if self._settings.migrate_from_env(env_path):
print("✓ Migration successful!")
print(f" - Secrets → System keyring")
print(f" - Settings → {self._settings.config_file}")
print()
print("Your .env file is kept as backup.")
print("You can delete it once you verify everything works.")
else:
print("✗ Migration failed. Using .env as fallback.")
print("="*60 + "\n")
# Load configuration
self.config = self._settings.get_all()
# Auto-default GITHUB_TOKEN to GITHUB_PAT if needed
self._apply_token_defaults()
# Show configuration status
self._print_config_status()
def _apply_token_defaults(self):
"""Auto-default GITHUB_TOKEN to GITHUB_PAT if GITHUB_TOKEN is empty"""
github_token = self.config.get('GITHUB_TOKEN', '').strip() if self.config.get('GITHUB_TOKEN') else ''
github_pat = self.config.get('GITHUB_PAT', '').strip() if self.config.get('GITHUB_PAT') else ''
if not github_token and github_pat:
self.config['GITHUB_TOKEN'] = github_pat
self._settings.set('GITHUB_TOKEN', github_pat, save=False)
def _print_config_status(self):
"""Print configuration load status"""
loaded_keys = []
for key, value in self.config.items():
if value and str(value).strip():
# Don't show actual secret values
if key in SettingsManager.SECRET_KEYS:
loaded_keys.append(f"{key}: loaded")
else:
loaded_keys.append(f"{key}: loaded")
if loaded_keys:
print(f"Configuration status: {', '.join(loaded_keys)}")
else:
print("No configuration values loaded - using defaults")
def load_configuration(self) -> Dict[str, Any]:
"""
Load configuration from new system (config.json + keyring).
Returns:
Dictionary of all settings
"""
self.config = self._settings.load()
self._apply_token_defaults()
return self.config
def save_configuration(self, config_values: Dict[str, Any]) -> bool:
"""
Save configuration using new system.
No restart required - changes apply immediately!
Args:
config_values: Settings to save
Returns:
True if successful
"""
# Save using new system
success = self._settings.save(config_values)
if success:
# Reload to get updated values
self.config = self._settings.get_all()
self._apply_token_defaults()
print(f"Configuration saved to {self._settings.config_file}")
print("Settings updated (no restart needed!)")
else:
print("Failed to save configuration")
return success
def get_config(self) -> Dict[str, Any]:
"""
Get current configuration with automatic GITHUB_TOKEN defaulting.
Returns:
Dictionary of all settings
"""
config = self.config.copy()
# Auto-default GITHUB_TOKEN to GITHUB_PAT if needed
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.
Args:
key: Setting key
default: Default value if not found
Returns:
Setting value or default
"""
return self._settings.get(key, default)
def get(self, key: str, default: Any = None) -> Any:
"""
Get a specific configuration value (dictionary-like interface).
Args:
key: Setting key
default: Default value if not found
Returns:
Setting value or default
"""
return self._settings.get(key, default)
def set_value(self, key: str, value: Any) -> None:
"""
Set a specific configuration value.
Args:
key: Setting key
value: New value
"""
self._settings.set(key, value)
self.config[key] = value
def register_listener(self, callback):
"""
Register a callback for settings changes (live updates).
The callback will be called with (key, new_value) when a setting changes.
Args:
callback: Function to call on settings change
Example:
def on_settings_changed(key, value):
if key == 'THEME_MODE':
# Update theme immediately
page.theme_mode = ft.ThemeMode.DARK if value == 'dark' else ft.ThemeMode.LIGHT
page.update()
config_manager.register_listener(on_settings_changed)
"""
self._settings.register_listener(callback)
def unregister_listener(self, callback):
"""
Unregister a settings change callback.
Args:
callback: Function to remove from listeners
"""
self._settings.unregister_listener(callback)
# Legacy methods for PR counter (unchanged)
def get_pr_counter_file(self) -> str:
"""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 {'count': 0}
def save_pr_counter(self, counter: Dict[str, int]) -> bool:
"""Save the PR counter to file"""
counter_file = self.get_pr_counter_file()
try:
with open(counter_file, 'w', encoding='utf-8') as f:
json.dump(counter, f, indent=2)
return True
except Exception as e:
print(f"Error saving PR counter: {e}")
return False
def increment_pr_counter(self) -> int:
"""Increment and return the PR counter"""
counter = self.load_pr_counter()
counter['count'] = counter.get('count', 0) + 1
self.save_pr_counter(counter)
return counter['count']
def get_pr_counter(self) -> int:
"""Get the current PR counter value"""
counter = self.load_pr_counter()
return counter.get('count', 0)
@@ -12,7 +12,7 @@ from urllib.parse import urlparse
# Constants # Constants
GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql" GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
USER_AGENT = "azure-devops-github-processor/2.0" USER_AGENT = "github-automation-tool/1.0"
class GitHubGQL: class GitHubGQL:
@@ -229,9 +229,7 @@ class GitHubGQL:
if self.dry_run: if self.dry_run:
# Return sample data for dry run # Return sample data for dry run
return [ return [
"username/fabric-docs", "username/repo_name",
"username/azure-docs",
"username/powerbi-docs"
] ]
try: try:
@@ -324,8 +322,8 @@ class GitHubGQL:
if self.dry_run: if self.dry_run:
return { return {
"target_alternatives": ["microsoftdocs/fabric-docs-pr"], "target_alternatives": ["username/target_repo_name"],
"fork_alternatives": ["b-tsammons/azure-docs-pr"] "fork_alternatives": ["username/fork_repo_name"]
} }
try: try:
@@ -552,7 +550,7 @@ class GitHubGQL:
raise raise
def create_pull_request(self, repository_id: str, title: str, body: str, head_ref: str, base_ref: str = "main") -> tuple[str, str, int]: def create_pull_request(self, repository_id: str, title: str, body: str, head_ref: str, base_ref: str = "main") -> tuple[str, str, int]:
"""Create a pull request with AB# linking""" """Create a pull request"""
self.log(f"Creating pull request with createPullRequest mutation from {head_ref} to {base_ref}...") self.log(f"Creating pull request with createPullRequest mutation from {head_ref} to {base_ref}...")
mutation = """ mutation = """
mutation($repositoryId:ID!, $title:String!, $body:String!, $headRefName:String!, $baseRefName:String!) { mutation($repositoryId:ID!, $title:String!, $body:String!, $headRefName:String!, $baseRefName:String!) {
@@ -664,8 +662,8 @@ class GitHubGQL:
old_text: Text to find and replace old_text: Text to find and replace
new_text: New text to replace with new_text: New text to replace with
branch_name: Branch name for this PR branch_name: Branch name for this PR
work_item_id: Work item or UUF issue ID work_item_id: Reference ID for tracking (optional)
item_source: Source of the item ('UUF' or 'Azure DevOps') item_source: Source of the item (optional)
Returns True if successful, False otherwise. Returns True if successful, False otherwise.
""" """
@@ -680,25 +678,22 @@ class GitHubGQL:
"User-Agent": USER_AGENT "User-Agent": USER_AGENT
} }
# Build work item reference # Build reference ID if provided
if work_item_id: if work_item_id:
if item_source == 'UUF': reference_id = f"**Reference ID:** {work_item_id}\n"
work_item_ref = f"**UUF Issue:** {work_item_id}\n"
else: else:
work_item_ref = f"**Azure DevOps Work Item:** AB#{work_item_id}\n" reference_id = ""
else:
work_item_ref = ""
# Build document reference # Build document reference
if file_path and not file_path.startswith("See work item") and not file_path.startswith("File path not specified"): if file_path and not file_path.startswith("File path not specified"):
doc_ref = f"**Document to modify:** `{file_path}`\n" doc_ref = f"**Document to modify:** `{file_path}`\n"
file_instruction = f"2. Locate the file: `{file_path}`" file_instruction = f"2. Locate the file: `{file_path}`"
elif doc_url: elif doc_url:
doc_ref = f"**Document URL:** {doc_url}\n" doc_ref = f"**Document URL:** {doc_url}\n"
file_instruction = f"2. Locate the file from this document URL: {doc_url}" file_instruction = f"2. Locate the file from this document URL: {doc_url}"
else: else:
doc_ref = "**Note:** File path not specified in work item\n" doc_ref = "**Note:** File path not specified\n"
file_instruction = "2. Review the PR description and work item details to identify the file(s) that need to be modified" file_instruction = "2. Review the PR description to identify the file(s) that need to be modified"
# Build custom instructions section # Build custom instructions section
if custom_instructions and custom_instructions.strip(): if custom_instructions and custom_instructions.strip():
@@ -713,25 +708,24 @@ class GitHubGQL:
# Create a comment mentioning @copilot with VERY explicit instructions # Create a comment mentioning @copilot with VERY explicit instructions
comment_body = f"""@copilot comment_body = f"""@copilot
{work_item_ref}{doc_ref} {reference_id}{doc_ref}
**Instructions:** **Instructions:**
Task: Update the documentation file with the changes requested above. Task: Update the file with the changes requested above.
Steps to complete: Steps to complete:
Locate the file containing the reference shown below. Locate the file containing the reference shown below.
Find the reference text within the file Find the reference text within the file
Replace it with the 'Proposed New Text' shown above or use the reference as guidance Replace it with the 'Proposed New Text' shown above or use the reference as guidance
Maintain the existing formatting, indentation, and markdown structure Maintain the existing formatting, indentation, and structure
Ensure no other content in the file is modified Ensure no other content in the file is modified
> [!IMPORTANT] > [!IMPORTANT]
> Only replace the specified text - do not make additional changes. > Only replace the specified text - do not make additional changes.
> Preserve all markdown formatting, links, and code blocks. > Preserve all formatting, links, and code blocks.
> If the current text cannot be found exactly, search for similar text. > 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. > 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. 1. Make changes to `{branch_name}` branch for this pull request.
@@ -748,14 +742,13 @@ Ensure no other content in the file is modified
{new_text} {new_text}
``` ```
5. Ensure the changes align with the context of the work item. 5. Ensure the changes align with the context provided.
6. Do a freshness check to ensure the file content is up-to-date before making changes. 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 7. Commit the changes to the `{branch_name}` branch
> [!NOTE] > [!NOTE]
> This documentation is maintained by spelluru.
> If guidance is empty, follow the reference to make changes. > If guidance is empty, follow the reference to make changes.
{custom_instructions_section} {custom_instructions_section}
@@ -848,7 +841,7 @@ Thank you!
{new_text} {new_text}
``` ```
**Automated Suggestion:** This change was requested in Azure DevOps work item. **Automated Suggestion:** This change was requested.
Click "Commit suggestion" above to apply this change directly to the PR.""" Click "Commit suggestion" above to apply this change directly to the PR."""
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
"""
Processing Log Dialog
Displays the processing log in a separate dialog window
"""
import flet as ft
# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors)
ft.icons = ft.Icons
ft.colors = ft.Colors
class ProcessingLogDialog:
"""Processing log display dialog"""
def __init__(self, page: ft.Page, log_text_ref: ft.Ref):
self.page = page
self.log_text_ref = log_text_ref
self.dialog_ref = ft.Ref[ft.AlertDialog]()
self.log_display_ref = ft.Ref[ft.TextField]()
def show(self):
"""Show the processing log dialog"""
try:
print("ProcessingLogDialog.show() called")
# Create the dialog
dialog = self._create_dialog()
self.dialog_ref.current = dialog
# Sync the log content before showing
self._sync_log_content()
# Open the dialog
self.page.open(dialog)
self.page.update()
except Exception as ex:
print(f"Error in ProcessingLogDialog.show(): {ex}")
import traceback
traceback.print_exc()
def _sync_log_content(self):
"""Sync log content from main log to dialog display"""
if self.log_text_ref.current and self.log_display_ref.current:
self.log_display_ref.current.value = self.log_text_ref.current.value
if self.page:
self.page.update()
def _create_dialog(self) -> ft.AlertDialog:
"""Create the processing log dialog"""
# Create a display field that will show a copy of the log
# This is synced from the main log field
log_display = ft.TextField(
ref=self.log_display_ref,
value=self.log_text_ref.current.value if self.log_text_ref.current else "",
multiline=True,
read_only=True,
expand=True,
text_style=ft.TextStyle(font_family="Courier New"),
min_lines=20,
max_lines=30,
)
# Refresh button
refresh_button = ft.TextButton(
"Refresh",
icon=ft.icons.REFRESH,
on_click=self._refresh_log,
)
# Clear button
clear_button = ft.TextButton(
"Clear Log",
icon=ft.icons.DELETE_OUTLINE,
on_click=self._clear_log,
)
# Close button
close_button = ft.TextButton(
"Close",
on_click=self._close_clicked,
)
dialog = ft.AlertDialog(
ref=self.dialog_ref,
modal=True,
title=ft.Row(
[
ft.Icon(ft.icons.LIST_ALT, color="blue"),
ft.Text("Processing Log", size=20, weight=ft.FontWeight.BOLD),
],
alignment=ft.MainAxisAlignment.START,
),
content=ft.Container(
content=log_display,
width=800,
height=500,
),
actions=[
refresh_button,
clear_button,
close_button,
],
actions_alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
)
return dialog
def _refresh_log(self, e):
"""Refresh the log content from the main log"""
self._sync_log_content()
def _clear_log(self, e):
"""Clear the log"""
# Clear both the main log and the display
if self.log_text_ref.current:
self.log_text_ref.current.value = ""
if self.log_display_ref.current:
self.log_display_ref.current.value = ""
self.page.update()
def _close_clicked(self, e):
"""Handle close button click"""
if self.dialog_ref.current:
self.page.close(self.dialog_ref.current)
File diff suppressed because it is too large Load Diff
+307
View File
@@ -0,0 +1,307 @@
"""
Settings Manager
Handles application settings with live updates and secure storage.
Non-secret settings are stored in config.json.
Secrets (API keys, tokens) are stored in the system keyring.
"""
import json
import os
from pathlib import Path
from typing import Dict, Any, Optional, Callable
import keyring
class SettingsManager:
"""
Manages application settings with live updates.
Features:
- Non-secret settings stored in JSON
- API keys stored securely in system keyring
- Live update notifications to registered listeners
- No app restart required for changes
"""
# Keyring service name for this app
SERVICE_NAME = "GitHubPulse"
# Keys that should be stored in keyring (secrets)
SECRET_KEYS = {
'GITHUB_PAT',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GITHUB_COPILOT_TOKEN',
'CLAUDE_API_KEY', # Alternative name for Anthropic
'GITHUB_TOKEN', # For GitHub Copilot
'OLLAMA_API_KEY', # Optional Ollama API key
}
# Default settings (non-secrets)
DEFAULT_SETTINGS = {
# GitHub Configuration
'GITHUB_REPO': '',
'FORKED_REPO': '',
'LOCAL_REPO_PATH': '',
# Application Settings
'AI_PROVIDER': 'none',
'DRY_RUN': 'false',
'DEFAULT_BRANCH': 'main',
'THEME_MODE': 'dark',
'AUTO_REFRESH': 'true',
'REFRESH_INTERVAL': '300',
# Ollama Configuration
'OLLAMA_URL': '',
'OLLAMA_MODEL': '',
# Custom AI Instructions
'CUSTOM_INSTRUCTIONS': '',
}
def __init__(self, config_dir: Optional[Path] = None):
"""
Initialize the settings manager.
Args:
config_dir: Directory to store config.json. Defaults to app directory.
"""
# Determine config directory
if config_dir is None:
# Use app directory
config_dir = Path(__file__).parent.parent
self.config_dir = Path(config_dir)
self.config_file = self.config_dir / "config.json"
# Settings storage
self._settings: Dict[str, Any] = {}
# Registered change listeners
self._listeners: list[Callable[[str, Any], None]] = []
# Load settings
self.load()
def load(self) -> Dict[str, Any]:
"""
Load settings from config.json and keyring.
Returns:
Dictionary of all settings (secrets and non-secrets combined)
"""
# Start with defaults
self._settings = self.DEFAULT_SETTINGS.copy()
# Load from JSON file
if self.config_file.exists():
try:
with open(self.config_file, 'r') as f:
saved_settings = json.load(f)
# Only load non-secret settings from JSON
for key, value in saved_settings.items():
if key not in self.SECRET_KEYS:
self._settings[key] = value
except Exception as e:
print(f"Error loading config.json: {e}")
# Load secrets from keyring
for secret_key in self.SECRET_KEYS:
try:
value = keyring.get_password(self.SERVICE_NAME, secret_key)
if value:
self._settings[secret_key] = value
except Exception as e:
print(f"Error loading {secret_key} from keyring: {e}")
return self._settings.copy()
def save(self, settings: Optional[Dict[str, Any]] = None) -> bool:
"""
Save settings to config.json and keyring.
Args:
settings: Settings to save. If None, saves current settings.
Returns:
True if successful, False otherwise
"""
if settings is not None:
# Update internal settings
for key, value in settings.items():
old_value = self._settings.get(key)
self._settings[key] = value
# Notify listeners of changes
if old_value != value:
self._notify_change(key, value)
try:
# Save non-secrets to JSON
json_settings = {
key: value for key, value in self._settings.items()
if key not in self.SECRET_KEYS
}
with open(self.config_file, 'w') as f:
json.dump(json_settings, f, indent=2)
# Save secrets to keyring
for secret_key in self.SECRET_KEYS:
if secret_key in self._settings:
value = self._settings[secret_key]
if value: # Only save non-empty values
try:
keyring.set_password(self.SERVICE_NAME, secret_key, str(value))
except Exception as e:
print(f"Error saving {secret_key} to keyring: {e}")
return True
except Exception as e:
print(f"Error saving settings: {e}")
return False
def get(self, key: str, default: Any = None) -> Any:
"""
Get a setting value.
Args:
key: Setting key
default: Default value if key doesn't exist
Returns:
Setting value or default
"""
return self._settings.get(key, default)
def set(self, key: str, value: Any, save: bool = True) -> bool:
"""
Set a setting value with live update.
Args:
key: Setting key
value: New value
save: Whether to persist immediately
Returns:
True if successful
"""
old_value = self._settings.get(key)
self._settings[key] = value
# Notify listeners
if old_value != value:
self._notify_change(key, value)
# Save if requested
if save:
return self.save()
return True
def get_all(self) -> Dict[str, Any]:
"""
Get all settings.
Returns:
Dictionary of all settings
"""
return self._settings.copy()
def register_listener(self, callback: Callable[[str, Any], None]):
"""
Register a callback to be notified of setting changes.
The callback will be called with (key, new_value) when a setting changes.
Args:
callback: Function to call on settings change
"""
if callback not in self._listeners:
self._listeners.append(callback)
def unregister_listener(self, callback: Callable[[str, Any], None]):
"""
Unregister a settings change callback.
Args:
callback: Function to remove from listeners
"""
if callback in self._listeners:
self._listeners.remove(callback)
def _notify_change(self, key: str, value: Any):
"""
Notify all registered listeners of a setting change.
Args:
key: Setting key that changed
value: New value
"""
for listener in self._listeners:
try:
listener(key, value)
except Exception as e:
print(f"Error notifying listener of {key} change: {e}")
def delete_secret(self, key: str) -> bool:
"""
Delete a secret from the keyring.
Args:
key: Secret key to delete
Returns:
True if successful
"""
if key not in self.SECRET_KEYS:
return False
try:
keyring.delete_password(self.SERVICE_NAME, key)
if key in self._settings:
del self._settings[key]
self._notify_change(key, None)
return True
except Exception as e:
print(f"Error deleting {key} from keyring: {e}")
return False
def migrate_from_env(self, env_file: Path) -> bool:
"""
Migrate settings from a .env file to the new system.
Args:
env_file: Path to .env file
Returns:
True if migration successful
"""
if not env_file.exists():
print(f"Env file not found: {env_file}")
return False
try:
# Read .env file
env_settings = {}
with open(env_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
env_settings[key] = value
# Save to new system
self.save(env_settings)
print(f"Successfully migrated {len(env_settings)} settings from .env")
return True
except Exception as e:
print(f"Error migrating from .env: {e}")
return False
@@ -192,11 +192,11 @@ class GitHubInfoExtractor:
class WorkItemFieldExtractor: class WorkItemFieldExtractor:
"""Extracts and processes work item fields""" """Extracts and processes item fields (placeholder for future implementation)"""
@staticmethod @staticmethod
def extract_work_item_fields(work_item: Dict[str, Any]) -> Dict[str, Any]: def extract_work_item_fields(work_item: Dict[str, Any]) -> Dict[str, Any]:
"""Extract and process fields from Azure DevOps work item""" """Extract and process fields from work item (placeholder)"""
fields = work_item.get('fields', {}) fields = work_item.get('fields', {})
# Extract basic fields # Extract basic fields
@@ -243,12 +243,12 @@ class WorkItemFieldExtractor:
'new_text': new_text, 'new_text': new_text,
'github_info': github_info, 'github_info': github_info,
'status': 'Ready', 'status': 'Ready',
'source': 'Azure DevOps' 'source': 'Generic'
} }
@staticmethod @staticmethod
def extract_uuf_item_fields(uuf_item: Dict[str, Any]) -> Dict[str, Any]: def extract_uuf_item_fields(uuf_item: Dict[str, Any]) -> Dict[str, Any]:
"""Extract and process fields from UUF item""" """Extract and process fields from custom item (placeholder)"""
# UUF items have different field structure # UUF items have different field structure
item_id = uuf_item.get('cr_uufitemid', 'Unknown') item_id = uuf_item.get('cr_uufitemid', 'Unknown')
title = uuf_item.get('cr_title', 'No Title') title = uuf_item.get('cr_title', 'No Title')
@@ -296,8 +296,10 @@ class ContentBuilders:
@staticmethod @staticmethod
def build_issue_title(item: Dict[str, Any]) -> str: def build_issue_title(item: Dict[str, Any]) -> str:
"""Build GitHub issue title""" """Build GitHub issue title"""
source_prefix = "UUF" if item.get('source') == 'UUF' else "AB" item_id = item.get('id', '')
return f"[{source_prefix}#{item['id']}] {item['title']}" if item_id:
return f"[#{item_id}] {item['title']}"
return f"{item['title']}"
@staticmethod @staticmethod
def build_issue_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str: def build_issue_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str:
@@ -305,8 +307,7 @@ class ContentBuilders:
body_parts = [] body_parts = []
# Header # Header
source_name = "UUF Item" if item.get('source') == 'UUF' else "Azure DevOps Work Item" body_parts.append("## Item Details")
body_parts.append(f"## {source_name} Details")
body_parts.append("") body_parts.append("")
# Make ID a hyperlink if source URL is available # Make ID a hyperlink if source URL is available
@@ -372,15 +373,17 @@ class ContentBuilders:
body_parts.append("5. Close this issue when complete") body_parts.append("5. Close this issue when complete")
body_parts.append("") body_parts.append("")
body_parts.append("---") body_parts.append("---")
body_parts.append("*Created automatically by Azure DevOps → GitHub Processor*") body_parts.append("*Created automatically by GitHub Pulse*")
return "\n".join(body_parts) return "\n".join(body_parts)
@staticmethod @staticmethod
def build_pr_title(item: Dict[str, Any]) -> str: def build_pr_title(item: Dict[str, Any]) -> str:
"""Build GitHub PR title""" """Build GitHub PR title"""
source_prefix = "UUF" if item.get('source') == 'UUF' else "AB" item_id = item.get('id', '')
return f"[{source_prefix}#{item['id']}] {item['title']}" if item_id:
return f"[#{item_id}] {item['title']}"
return f"{item['title']}"
@staticmethod @staticmethod
def build_pr_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str: def build_pr_body(item: Dict[str, Any], github_info: Dict[str, Any]) -> str:
@@ -388,8 +391,7 @@ class ContentBuilders:
body_parts = [] body_parts = []
# Header # Header
source_name = "UUF Item" if item.get('source') == 'UUF' else "Azure DevOps Work Item" body_parts.append("## Documentation Update")
body_parts.append(f"## {source_name} Documentation Update")
body_parts.append("") body_parts.append("")
# Make ID a hyperlink if source URL is available # Make ID a hyperlink if source URL is available
@@ -448,7 +450,7 @@ class ContentBuilders:
body_parts.append("") body_parts.append("")
body_parts.append("---") body_parts.append("---")
body_parts.append("*Created automatically by Azure DevOps → GitHub Processor*") body_parts.append("*Created automatically by GitHub Pulse*")
return "\n".join(body_parts) return "\n".join(body_parts)
@@ -612,17 +614,14 @@ class ConfigurationHelpers:
def create_default_env_file() -> bool: def create_default_env_file() -> bool:
"""Create a default .env file with all settings blank""" """Create a default .env file with all settings blank"""
try: try:
default_config = """# Azure DevOps to GitHub Tool Configuration default_config = """# GitHub Pulse Configuration
# Generated automatically - fill in your values # Generated automatically - fill in your values
# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore. # 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 Configuration
GITHUB_PAT= GITHUB_PAT=
GITHUB_REPO= GITHUB_REPO=
FORKED_REPO=
# Application Settings # Application Settings
DRY_RUN=false DRY_RUN=false
@@ -634,12 +633,8 @@ OPENAI_API_KEY=
GITHUB_TOKEN= GITHUB_TOKEN=
LOCAL_REPO_PATH= LOCAL_REPO_PATH=
# PowerApp/Dataverse Configuration (for UUF items - optional) # Custom AI Instructions (optional)
DATAVERSE_ENVIRONMENT_URL= CUSTOM_INSTRUCTIONS=
DATAVERSE_TABLE_NAME=
AZURE_AD_CLIENT_ID=
AZURE_AD_CLIENT_SECRET=
AZURE_AD_TENANT_ID=
""" """
with open('.env', 'w', encoding='utf-8') as f: with open('.env', 'w', encoding='utf-8') as f:
f.write(default_config) f.write(default_config)
@@ -651,81 +646,6 @@ AZURE_AD_TENANT_ID=
print(f"Error creating default .env file: {e}") print(f"Error creating default .env file: {e}")
return False 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 # Compatibility functions for direct function access
def get_next_pr_number(provider_key: str) -> int: def get_next_pr_number(provider_key: str) -> int:
"""Compatibility function for direct access to PR number generation""" """Compatibility function for direct access to PR number generation"""
+653
View File
@@ -0,0 +1,653 @@
"""
Workflow Manager
Manages GitHub workflow items (Issues and Pull Requests) from target and fork repositories
"""
import requests
from typing import List, Dict, Any, Optional, Tuple
class WorkflowItem:
"""Represents a GitHub workflow item (Issue or PR)"""
def __init__(self, item_type: str, data: Dict[str, Any], repo_source: str):
"""
Initialize a workflow item
Args:
item_type: 'issue' or 'pull_request'
data: Raw data from GitHub API
repo_source: 'target' or 'fork'
"""
self.item_type = item_type
self.repo_source = repo_source
self.data = data
# Extract common fields
self.number = data.get('number')
self.title = data.get('title', 'No Title')
self.state = data.get('state', 'unknown')
self.created_at = data.get('created_at', '')
self.updated_at = data.get('updated_at', '')
self.body = data.get('body', '')
self.url = data.get('html_url', '')
self.api_url = data.get('url', '')
# Author information
user = data.get('user', {})
self.author = user.get('login', 'unknown') if user else 'unknown'
self.author_url = user.get('html_url', '') if user else ''
# Labels
self.labels = [label.get('name', '') for label in data.get('labels', [])]
# Assignees
assignees = data.get('assignees', [])
self.assignees = [a.get('login', '') for a in assignees if a]
# PR-specific fields
if item_type == 'pull_request':
self.is_draft = data.get('draft', False)
self.mergeable_state = data.get('mergeable_state', 'unknown')
self.merged = data.get('merged', False)
self.base_ref = data.get('base', {}).get('ref', '')
self.head_ref = data.get('head', {}).get('ref', '')
else:
self.is_draft = False
self.mergeable_state = None
self.merged = False
self.base_ref = None
self.head_ref = None
# Comments count
self.comments_count = data.get('comments', 0)
def __repr__(self):
return f"<WorkflowItem {self.item_type} #{self.number}: {self.title[:50]}>"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for easy serialization"""
return {
'item_type': self.item_type,
'repo_source': self.repo_source,
'data': self.data, # Include raw data for full reconstruction
'number': self.number,
'title': self.title,
'state': self.state,
'created_at': self.created_at,
'updated_at': self.updated_at,
'body': self.body,
'url': self.url,
'api_url': self.api_url,
'author': self.author,
'author_url': self.author_url,
'labels': self.labels,
'assignees': self.assignees,
'is_draft': self.is_draft,
'mergeable_state': self.mergeable_state,
'merged': self.merged,
'base_ref': self.base_ref,
'head_ref': self.head_ref,
'comments_count': self.comments_count
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'WorkflowItem':
"""Create WorkflowItem from dictionary (for cache deserialization)"""
# Extract the raw GitHub API data if available, otherwise use the dict itself
raw_data = data.get('data', data)
item_type = data.get('item_type', 'issue')
repo_source = data.get('repo_source', 'target')
return cls(item_type, raw_data, repo_source)
class GitHubRepoFetcher:
"""Fetches repository information from GitHub"""
def __init__(self, github_token: str, logger=None):
"""
Initialize the repo fetcher
Args:
github_token: GitHub Personal Access Token
logger: Optional logger instance
"""
self.token = github_token
self.logger = logger
self.headers = {
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"User-Agent": "github-automation-tool/1.0"
}
def log(self, message: str):
"""Log a message"""
if self.logger:
self.logger.log(message)
else:
print(message)
def get_authenticated_user(self) -> Optional[Dict[str, Any]]:
"""
Get information about the authenticated user
Returns:
Dictionary with user information or None if error
"""
try:
url = "https://api.github.com/user"
response = requests.get(url, headers=self.headers, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
self.log(f"❌ Error fetching authenticated user: {str(e)}")
return None
def fetch_user_repos(self, repo_type: str = 'owner', per_page: int = 100) -> List[Dict[str, Any]]:
"""
Fetch repositories for the authenticated user
Args:
repo_type: 'owner', 'member', or 'all'
per_page: Number of repos per page (max 100)
Returns:
List of repository dictionaries
"""
try:
url = "https://api.github.com/user/repos"
params = {
'type': repo_type,
'per_page': min(per_page, 100),
'sort': 'updated',
'direction': 'desc'
}
response = requests.get(url, headers=self.headers, params=params, timeout=30)
response.raise_for_status()
repos = response.json()
self.log(f"✅ Found {len(repos)} repositories ({repo_type})")
return repos
except Exception as e:
self.log(f"❌ Error fetching user repos: {str(e)}")
return []
def fetch_repos_with_permissions(self, min_permission: str = 'push') -> List[Dict[str, Any]]:
"""
Fetch repositories where user has specific permissions
Args:
min_permission: Minimum permission level ('pull', 'push', 'admin')
Returns:
List of repository dictionaries with sufficient permissions
"""
try:
# Fetch all repos user has access to
all_repos = self.fetch_user_repos(repo_type='all')
# Filter by permission level
filtered_repos = []
permission_levels = {'pull': 0, 'push': 1, 'admin': 2}
min_level = permission_levels.get(min_permission, 1)
for repo in all_repos:
permissions = repo.get('permissions', {})
# Check permission level
if permissions.get('admin'):
level = 2
elif permissions.get('push'):
level = 1
elif permissions.get('pull'):
level = 0
else:
level = -1
if level >= min_level:
filtered_repos.append(repo)
self.log(f"✅ Found {len(filtered_repos)} repos with '{min_permission}' permission or higher")
return filtered_repos
except Exception as e:
self.log(f"❌ Error fetching repos with permissions: {str(e)}")
return []
def search_repositories(self, query: str, per_page: int = 30) -> List[Dict[str, Any]]:
"""
Search for repositories on GitHub
Args:
query: Search query string
per_page: Number of results per page (max 100)
Returns:
List of repository dictionaries
"""
if not query or not query.strip():
return []
try:
url = "https://api.github.com/search/repositories"
params = {
'q': query.strip(),
'per_page': min(per_page, 100),
'sort': 'updated',
'order': 'desc'
}
response = requests.get(url, headers=self.headers, params=params, timeout=30)
response.raise_for_status()
data = response.json()
repos = data.get('items', [])
total_count = data.get('total_count', 0)
self.log(f"✅ Search found {total_count} repositories (showing {len(repos)})")
return repos
except Exception as e:
self.log(f"❌ Error searching repositories: {str(e)}")
return []
def get_repo_names(self, repos: List[Dict[str, Any]]) -> List[str]:
"""
Extract repository names in 'owner/repo' format
Args:
repos: List of repository dictionaries
Returns:
List of repository name strings
"""
return [repo.get('full_name', '') for repo in repos if repo.get('full_name')]
class WorkflowManager:
"""Manages workflow items from GitHub repositories"""
def __init__(self, github_token: str, logger=None):
"""
Initialize the workflow manager
Args:
github_token: GitHub Personal Access Token
logger: Optional logger instance
"""
self.token = github_token
self.logger = logger
self.headers = {
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"User-Agent": "github-automation-tool/1.0"
}
# Initialize repo fetcher
self.repo_fetcher = GitHubRepoFetcher(github_token, logger)
def log(self, message: str):
"""Log a message"""
if self.logger:
self.logger.log(message)
else:
print(message)
def _parse_repo(self, repo_str: str) -> Optional[Tuple[str, str]]:
"""
Parse a repository string into owner and name
Args:
repo_str: Repository string in format "owner/repo"
Returns:
Tuple of (owner, repo) or None if invalid
"""
if not repo_str or '/' not in repo_str:
return None
parts = repo_str.strip().split('/')
if len(parts) != 2:
return None
return parts[0], parts[1]
def fetch_issues(self, repo_str: str, repo_source: str = 'target',
state: str = 'all', per_page: int = 100) -> List[WorkflowItem]:
"""
Fetch issues from a repository
Args:
repo_str: Repository string in format "owner/repo"
repo_source: 'target' or 'fork' to identify source
state: 'open', 'closed', or 'all'
per_page: Number of items per page (max 100)
Returns:
List of WorkflowItem objects
"""
parsed = self._parse_repo(repo_str)
if not parsed:
self.log(f"L Invalid repository format: {repo_str}")
return []
owner, repo = parsed
self.log(f"Fetching issues from {owner}/{repo} ({repo_source})...")
try:
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
params = {
'state': state,
'per_page': min(per_page, 100),
'sort': 'updated',
'direction': 'desc'
}
response = requests.get(url, headers=self.headers, params=params, timeout=30)
response.raise_for_status()
items_data = response.json()
# Filter out pull requests (GitHub's issues endpoint includes PRs)
issues_data = [item for item in items_data if 'pull_request' not in item]
issues = [WorkflowItem('issue', data, repo_source) for data in issues_data]
self.log(f" Found {len(issues)} issues in {owner}/{repo}")
return issues
except requests.HTTPError as e:
self.log(f"L HTTP Error fetching issues from {owner}/{repo}: {e}")
if e.response.status_code == 401:
self.log(" Check your GitHub Personal Access Token")
elif e.response.status_code == 404:
self.log(" Repository not found or no access")
return []
except Exception as e:
self.log(f"L Error fetching issues from {owner}/{repo}: {str(e)}")
return []
def fetch_pull_requests(self, repo_str: str, repo_source: str = 'target',
state: str = 'all', per_page: int = 100) -> List[WorkflowItem]:
"""
Fetch pull requests from a repository
Args:
repo_str: Repository string in format "owner/repo"
repo_source: 'target' or 'fork' to identify source
state: 'open', 'closed', or 'all'
per_page: Number of items per page (max 100)
Returns:
List of WorkflowItem objects
"""
parsed = self._parse_repo(repo_str)
if not parsed:
self.log(f"L Invalid repository format: {repo_str}")
return []
owner, repo = parsed
self.log(f"Fetching pull requests from {owner}/{repo} ({repo_source})...")
try:
url = f"https://api.github.com/repos/{owner}/{repo}/pulls"
params = {
'state': state,
'per_page': min(per_page, 100),
'sort': 'updated',
'direction': 'desc'
}
response = requests.get(url, headers=self.headers, params=params, timeout=30)
response.raise_for_status()
prs_data = response.json()
prs = [WorkflowItem('pull_request', data, repo_source) for data in prs_data]
self.log(f" Found {len(prs)} pull requests in {owner}/{repo}")
return prs
except requests.HTTPError as e:
self.log(f"L HTTP Error fetching PRs from {owner}/{repo}: {e}")
if e.response.status_code == 401:
self.log(" Check your GitHub Personal Access Token")
elif e.response.status_code == 404:
self.log(" Repository not found or no access")
return []
except Exception as e:
self.log(f"L Error fetching PRs from {owner}/{repo}: {str(e)}")
return []
def fetch_all_workflow_items(self, target_repo: str, fork_repo: str = None,
include_issues: bool = True,
include_prs: bool = True,
state: str = 'all') -> Dict[str, List[WorkflowItem]]:
"""
Fetch all workflow items from both target and fork repositories
Args:
target_repo: Target repository string "owner/repo"
fork_repo: Fork repository string "owner/repo" (optional)
include_issues: Whether to fetch issues
include_prs: Whether to fetch pull requests
state: 'open', 'closed', or 'all'
Returns:
Dictionary with keys 'target_issues', 'target_prs', 'fork_issues', 'fork_prs'
"""
results = {
'target_issues': [],
'target_prs': [],
'fork_issues': [],
'fork_prs': []
}
# Fetch from target repository
if target_repo:
if include_issues:
results['target_issues'] = self.fetch_issues(target_repo, 'target', state)
if include_prs:
results['target_prs'] = self.fetch_pull_requests(target_repo, 'target', state)
# Fetch from fork repository
if fork_repo:
if include_issues:
results['fork_issues'] = self.fetch_issues(fork_repo, 'fork', state)
if include_prs:
results['fork_prs'] = self.fetch_pull_requests(fork_repo, 'fork', state)
# Log summary
total = sum(len(items) for items in results.values())
self.log(f"\n= Summary: Fetched {total} total items")
self.log(f" Target Issues: {len(results['target_issues'])}")
self.log(f" Target PRs: {len(results['target_prs'])}")
if fork_repo:
self.log(f" Fork Issues: {len(results['fork_issues'])}")
self.log(f" Fork PRs: {len(results['fork_prs'])}")
return results
def get_combined_items(self, workflow_items: Dict[str, List[WorkflowItem]],
sort_by: str = 'updated') -> List[WorkflowItem]:
"""
Combine and sort all workflow items
Args:
workflow_items: Dictionary from fetch_all_workflow_items()
sort_by: 'updated', 'created', or 'number'
Returns:
Sorted list of all workflow items
"""
all_items = []
for items_list in workflow_items.values():
all_items.extend(items_list)
# Sort items
if sort_by == 'updated':
all_items.sort(key=lambda x: x.updated_at, reverse=True)
elif sort_by == 'created':
all_items.sort(key=lambda x: x.created_at, reverse=True)
elif sort_by == 'number':
all_items.sort(key=lambda x: x.number, reverse=True)
return all_items
def filter_items(self, items: List[WorkflowItem], **filters) -> List[WorkflowItem]:
"""
Filter workflow items based on criteria
Args:
items: List of WorkflowItem objects
**filters: Filter criteria (state, item_type, repo_source, author, labels)
Returns:
Filtered list of items
"""
filtered = items
if 'state' in filters and filters['state']:
filtered = [item for item in filtered if item.state == filters['state']]
if 'item_type' in filters and filters['item_type']:
filtered = [item for item in filtered if item.item_type == filters['item_type']]
if 'repo_source' in filters and filters['repo_source']:
filtered = [item for item in filtered if item.repo_source == filters['repo_source']]
if 'author' in filters and filters['author']:
filtered = [item for item in filtered if item.author == filters['author']]
if 'labels' in filters and filters['labels']:
label_filter = filters['labels']
if isinstance(label_filter, str):
label_filter = [label_filter]
filtered = [item for item in filtered
if any(label in item.labels for label in label_filter)]
return filtered
def fetch_comments(self, repo_str: str, issue_number: int, is_pull_request: bool = False) -> List[Dict[str, Any]]:
"""
Fetch comments for an issue or pull request
Args:
repo_str: Repository string in format "owner/repo"
issue_number: Issue or PR number
is_pull_request: Whether this is a pull request (for PR-specific comments)
Returns:
List of comment dictionaries with keys: 'user', 'body', 'created_at', 'updated_at'
"""
try:
# Parse repository string
if '/' not in repo_str:
self.log(f"Invalid repository format: {repo_str}")
return []
owner, repo = repo_str.split('/', 1)
# Fetch issue/PR comments (these are the same endpoint for both issues and PRs)
url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments"
print(f"DEBUG: Fetching comments from URL: {url}", flush=True)
response = requests.get(url, headers=self.headers)
print(f"DEBUG: Response status code: {response.status_code}", flush=True)
print(f"DEBUG: Response headers: {dict(response.headers)}", flush=True)
print(f"DEBUG: Response text length: {len(response.text)}", flush=True)
print(f"DEBUG: Response content (first 500): {response.text[:500]}", flush=True)
response.raise_for_status()
response_data = response.json()
print(f"DEBUG: Response data type: {type(response_data)}", flush=True)
print(f"DEBUG: Number of items: {len(response_data) if isinstance(response_data, list) else 'Not a list'}", flush=True)
if isinstance(response_data, list) and len(response_data) > 0:
print(f"DEBUG: First item keys: {list(response_data[0].keys())}", flush=True)
comments = []
for comment_data in response_data:
comments.append({
'user': comment_data.get('user', {}).get('login', 'unknown'),
'body': comment_data.get('body', ''),
'created_at': comment_data.get('created_at', ''),
'updated_at': comment_data.get('updated_at', ''),
'url': comment_data.get('html_url', '')
})
self.log(f"Fetched {len(comments)} comments for {repo_str} #{issue_number}")
print(f"DEBUG: Successfully parsed {len(comments)} comments", flush=True)
return comments
except requests.exceptions.RequestException as e:
self.log(f"Error fetching comments for {repo_str} #{issue_number}: {e}")
print(f"DEBUG: RequestException occurred: {e}", flush=True)
import traceback
traceback.print_exc()
return []
except Exception as e:
self.log(f"Unexpected error fetching comments: {e}")
print(f"DEBUG: Exception occurred: {e}", flush=True)
import traceback
traceback.print_exc()
return []
def fetch_pr_files(self, repo_str: str, pr_number: int) -> List[Dict[str, Any]]:
"""
Fetch the list of files changed in a pull request
Args:
repo_str: Repository string in format "owner/repo"
pr_number: Pull request number
Returns:
List of file dictionaries with keys: 'filename', 'status', 'additions', 'deletions', 'changes', 'patch'
"""
try:
# Parse repository string
if '/' not in repo_str:
self.log(f"Invalid repository format: {repo_str}")
return []
owner, repo = repo_str.split('/', 1)
# Fetch PR files
url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files"
print(f"DEBUG: Fetching PR files from URL: {url}", flush=True)
response = requests.get(url, headers=self.headers)
response.raise_for_status()
files_data = response.json()
print(f"DEBUG: Found {len(files_data)} files in PR #{pr_number}", flush=True)
files = []
for file_data in files_data:
files.append({
'filename': file_data.get('filename', ''),
'status': file_data.get('status', ''), # added, removed, modified, renamed
'additions': file_data.get('additions', 0),
'deletions': file_data.get('deletions', 0),
'changes': file_data.get('changes', 0),
'patch': file_data.get('patch', ''), # The actual diff patch
'blob_url': file_data.get('blob_url', ''),
})
self.log(f"Fetched {len(files)} files for PR {repo_str} #{pr_number}")
return files
except requests.exceptions.RequestException as e:
self.log(f"Error fetching PR files for {repo_str} #{pr_number}: {e}")
print(f"DEBUG: RequestException occurred: {e}", flush=True)
import traceback
traceback.print_exc()
return []
except Exception as e:
self.log(f"Unexpected error fetching PR files: {e}")
print(f"DEBUG: Exception occurred: {e}", flush=True)
import traceback
traceback.print_exc()
return []
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+218
View File
@@ -0,0 +1,218 @@
"""
GitHub Pulse
Main application entry point
This application provides GitHub automation workflows with AI assistance.
Note: You may see a Flutter engine warning when closing the app:
"embedder.cc (2519): 'FlutterEngineRemoveView' returned 'kInvalidArguments'"
This is a harmless known issue with Flet/Flutter and can be safely ignored.
"""
import sys
import os
import flet as ft
# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors)
ft.icons = ft.Icons
ft.colors = ft.Colors
# 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.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")
# In production builds, show a user-friendly error
if getattr(sys, 'frozen', False):
import traceback
error_details = traceback.format_exc()
print(error_details)
sys.exit(1)
class GitHubAutomationApp:
"""Main application class that orchestrates all components"""
def __init__(self, page: ft.Page):
"""Initialize the application"""
self.page = page
# Configure page
self.page.title = "GitHub Pulse"
self.page.theme_mode = ft.ThemeMode.DARK
self.page.padding = 0
# Set window size with platform detection
# Mobile devices will use full screen
is_mobile = page.web or (hasattr(page, 'platform') and
page.platform in ['android', 'ios'])
if not is_mobile:
self.page.window_width = 1400
self.page.window_height = 1000
self.page.window_min_width = 1200
self.page.window_min_height = 800
# Material Design 3 theme with optimized settings
self.page.theme = ft.Theme(
color_scheme_seed="blue",
use_material3=True,
)
# 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')
# Register listener for live settings updates
self.config_manager.register_listener(self._on_setting_changed)
# Initialize main GUI
self.main_gui = MainGUI(
page=self.page,
config_manager=self.config_manager,
ai_manager=self.ai_manager,
app=self
)
# Build UI
self.page.add(self.main_gui.build())
# Check AI provider setup after a short delay
self.page.run_task(self._check_ai_provider_setup_async)
async def _check_ai_provider_setup_async(self):
"""Check and setup AI providers after GUI initialization"""
try:
# Wait a bit for GUI to fully load
import asyncio
await asyncio.sleep(0.5)
ai_provider = self.config.get('AI_PROVIDER', '').strip().lower()
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
await self.ai_manager.check_and_install_ai_modules_async(ai_provider, self.page)
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 _on_setting_changed(self, key: str, value: any):
"""
Handle settings changes with live updates (no restart needed!)
Args:
key: Setting key that changed
value: New value
"""
print(f"⚡ Setting changed: {key} = {value}")
# Theme changes - apply immediately
if key == 'THEME_MODE':
if value == 'dark':
self.page.theme_mode = ft.ThemeMode.DARK
elif value == 'light':
self.page.theme_mode = ft.ThemeMode.LIGHT
self.page.update()
print(f"✓ Theme updated to {value}")
# Dry run mode changes
elif key == 'DRY_RUN':
self.dry_run_enabled = str(value).lower() in ('true', '1', 'yes', 'on')
print(f"✓ Dry run mode: {self.dry_run_enabled}")
# GitHub token changes - reinitialize API
elif key == 'GITHUB_PAT':
if hasattr(self, 'main_gui') and self.main_gui:
print("✓ GitHub token updated - API will be reinitialized on next use")
# AI provider changes
elif key == 'AI_PROVIDER':
print(f"✓ AI provider changed to: {value}")
# AI manager will use new provider on next request
# Update internal config
self.config[key] = value
async def main(page: ft.Page):
"""Main entry point for Flet application"""
try:
app = GitHubAutomationApp(page)
except Exception as e:
# Show error as a simple text on the page since dialog can't open before page init
print(f"Failed to start application: {e}")
import traceback
traceback.print_exc()
# Add error message to page
error_text = ft.Text(
f"Application Error:\n\n{str(e)}\n\nPlease check the console for details.",
color="red",
size=16,
)
page.add(error_text)
if __name__ == "__main__":
# Run the Flet app with optimized settings
# For production builds, use appropriate view settings
is_production = getattr(sys, 'frozen', False)
if is_production:
# Production build settings
ft.app(
target=main,
view=ft.AppView.FLET_APP, # Native app view for builds
assets_dir="assets" # Ensure assets are loaded correctly
)
else:
# Development settings
ft.app(
target=main,
assets_dir="assets"
)
+11
View File
@@ -0,0 +1,11 @@
# AI Provider dependencies
# These are optional and only needed if using AI features
# Include base requirements
-r requirements-base.txt
# AI Providers (optional - install only what you need)
openai>=2.8.0 # For ChatGPT integration
anthropic>=0.72.1 # For Claude integration
# Note: Ollama and GitHub Copilot use REST APIs and don't require additional packages
+14
View File
@@ -0,0 +1,14 @@
# Base requirements for all platforms
# Core dependencies required for the application to run
# UI Framework (Flet - Python wrapper for Flutter)
flet==0.28.3
# HTTP requests for API calls
requests>=2.32.5
# Secure credential storage (cross-platform)
keyring>=25.6.0
# Git operations for repository management
GitPython>=3.1.45
+12
View File
@@ -0,0 +1,12 @@
# Development requirements
# Includes all dependencies for development, testing, and building
# Include AI requirements (which includes base)
-r requirements-ai.txt
# Development tools
flet[all]==0.28.3 # Flet with all development extras
# Build tools (optional)
# pyinstaller>=6.0.0 # Alternative bundler if needed
# cx-Freeze>=6.15.0 # Alternative bundler if needed
+23
View File
@@ -0,0 +1,23 @@
# GitHub Pulse - Main Requirements File
# This file includes all dependencies needed for production use
# Core dependencies
requests>=2.32.5 # HTTP client for GitHub API and AI providers
keyring>=25.6.0 # Secure credential storage (cross-platform)
GitPython>=3.1.45 # Git operations for repository management
# UI Framework - Flet (Python wrapper for Flutter)
flet==0.28.3 # Pin to specific version for build compatibility
# AI Providers (optional but included for full functionality)
openai>=2.8.0 # ChatGPT/OpenAI API integration
anthropic>=0.72.1 # Claude/Anthropic API integration
# Platform-specific notes:
# - Linux: Requires libgtk-3-dev, clang, cmake, ninja-build
# - Windows: Requires Visual Studio 2016+
# - Android: Requires Android Studio and SDK
# - iOS/macOS: Requires Xcode
# For minimal installation (no AI), use: pip install -r requirements-base.txt
# For development: pip install -r requirements-dev.txt