diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8b03e50 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Run Tests + +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + make install + + - name: Run tests with coverage + run: | + pytest tests/test_triggers.py -v --cov --cov-branch --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c4d0a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.aider* +.env +src/repos diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..78fb602 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +.PHONY: all install install-deps install-aider test clean + +all: install + +install: install-deps install-aider + +install-deps: + @echo "Installing Python dependencies..." + python -m pip install -r requirements.txt + +install-aider: + @echo "Installing aider..." + python -m pip install aider-install + aider-install + +test: + @echo "Running tests..." + pytest + +clean: + @echo "Cleaning up..." + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name "*.pyd" -delete + find . -type d -name "*.egg-info" -exec rm -rf {} + + find . -type d -name "*.egg" -exec rm -rf {} + + find . -type d -name ".pytest_cache" -exec rm -rf {} + + find . -type d -name ".coverage" -exec rm -rf {} + + find . -type d -name "htmlcov" -exec rm -rf {} + + find . -type d -name "dist" -exec rm -rf {} + + find . -type d -name "build" -exec rm -rf {} + + +help: + @echo "Available targets:" + @echo " all : Install dependencies and aider (default)" + @echo " install : Install dependencies and aider" + @echo " install-deps : Install Python dependencies from requirements.txt" + @echo " install-aider: Install aider tool" + @echo " test : Run tests with pytest" + @echo " clean : Remove Python cache files and build artifacts" + @echo " help : Show this help message" diff --git a/README.md b/README.md index 27951f6..4abdb07 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/abuzarmahmood/blech_github_bot/main.svg)](https://results.pre-commit.ci/latest/github/abuzarmahmood/blech_github_bot/main) +[![codecov](https://codecov.io/gh/abuzarmahmood/blech_github_bot/branch/main/graph/badge.svg)](https://codecov.io/gh/abuzarmahmood/blech_github_bot) # GitHub Monitor Bot @@ -7,18 +8,25 @@ A Python bot that monitors GitHub repositories and automatically responds to iss ## Features - Monitors configured GitHub repositories from `config/repos.txt` -- Automatically responds to issues with the `blech_bot` label -- Analyzes issue content using GPT-4 +- Automatically responds to issues with the `blech_bot` label or title mention +- Analyzes issue content using GPT-4o - Suggests relevant files and code changes - Clones and updates local repository copies - Provides detailed code review and suggestions - Tracks response history to avoid duplicates +- Creates development branches and pull requests from issues +- Implements changes automatically using Aider +- Processes user feedback on responses and pull requests +- Extracts and analyzes content from URLs in issues +- Self-updates while preserving configuration ## Requirements - Python 3.8+ - OpenAI API key - GitHub API access token +- GitHub CLI (gh) +- Aider CLI - Required Python packages (see requirements.txt): - pyautogen - PyGithub @@ -26,8 +34,11 @@ A Python bot that monitors GitHub repositories and automatically responds to iss - gitpython - requests - pyyaml + - urlextract + - beautifulsoup4 + - aider-chat -## Setup +## Get Started 1. Clone the repository: ```bash @@ -43,9 +54,14 @@ source venv/bin/activate # On Linux/Mac .\venv\Scripts\activate # On Windows ``` -3. Install dependencies: +3. Install dependencies and tools: ```bash -pip install -r requirements.txt +# Install all dependencies and aider +make install + +# Or install components separately +make install-deps # Just Python dependencies +make install-aider # Just aider tool ``` 4. Create a `.env` file with your API tokens: @@ -60,6 +76,23 @@ owner/repo1 owner/repo2 ``` +6. Configure bot behavior in `config/params.json`: +```json +{ + "auto_update": true +} +``` +- `auto_update`: Controls whether the bot automatically updates itself with the latest changes from its repository. Set to `true` to enable auto-updates or `false` to disable them. + +7. Run the bot: +```bash +# Run once +python src/response_agent.py + +# Or run continuously with the shell script +./src/run_response_agent.sh --delay 300 # Check every 5 minutes +``` + ## Usage Run the bot: @@ -71,54 +104,29 @@ The bot will: 1. Connect to GitHub using your API token 2. Clone/update configured repositories locally 3. Process open issues that: - - Have the `blech_bot` label - - Don't already have a bot response - - Don't have associated branches/PRs -4. Generate and post responses using GPT-4 + - Have the `blech_bot` label or "[ blech_bot ]" in the title + - Don't already have a bot response (for new issues) + - Have user feedback (for follow-up responses) + - Have development commands (for creating PRs) +4. Generate and post responses using GPT-4o +5. Create branches and PRs when requested with "[ develop_issue ]" command +6. Apply changes automatically using Aider when feedback is provided on PRs ## Code Structure - `src/response_agent.py`: Main bot logic and Autogen agents - `src/git_utils.py`: GitHub API interaction utilities - `src/bot_tools.py`: Helper functions for file operations +- `src/agents.py`: Agent definitions and prompt generation +- `src/branch_handler.py`: Git branch management utilities +- `src/triggers.py`: Issue trigger detection functions +- `src/run_response_agent.sh`: Script for continuous bot operation - `config/repos.txt`: List of repositories to monitor - -## Get Started - -1. Set up your environment: -```bash -# Create and activate virtual environment -python -m venv venv -source venv/bin/activate - -# Install dependencies -pip install -r requirements.txt -``` - -2. Configure your environment variables in `.env`: -``` -GITHUB_TOKEN=your_github_token -OPENAI_API_KEY=your_openai_key -``` - -3. Add repositories to monitor in `config/repos.txt`: -``` -owner/repo1 -owner/repo2 -``` - -4. Run the bot: -```bash -# Run once -python src/response_agent.py - -# Or run continuously with the shell script -./src/run_response_agent.sh --delay 300 # Check every 5 minutes -``` +- `config/params.json`: Bot configuration parameters ## AI Agent Architecture -The bot uses specialized GPT-4 agents working together through Autogen: +The bot uses specialized GPT-4o agents working together through Autogen: - **File Assistant**: Analyzes repository structure - Reviews issue content and codebase @@ -144,11 +152,23 @@ The bot uses specialized GPT-4 agents working together through Autogen: - Maintains context from original response - Generates updated recommendations +- **Comment Summary Assistant**: Summarizes issue comments + - Extracts relevant information from comment threads + - Identifies key points and requirements + - Provides concise summaries for other agents + +- **Generate Edit Command Assistant**: Creates Aider commands + - Converts discussion into actionable edit instructions + - Generates precise commands for automated implementation + - Formats instructions for Aider compatibility + The agents work together to: 1. Analyze issues and identify affected files 2. Propose specific code changes 3. Generate comprehensive responses 4. Process user feedback and improve suggestions +5. Automatically implement changes via Aider +6. Create and manage pull requests ## Contributing diff --git a/config/params.json b/config/params.json new file mode 100644 index 0000000..8555d85 --- /dev/null +++ b/config/params.json @@ -0,0 +1,4 @@ +{ + "auto_update": false, + "print_llm_output": false +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..59ace49 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --cov=src --cov-report=term-missing --cov-report=xml diff --git a/requirements.txt b/requirements.txt index 4d8c79f..6affd55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,9 @@ python-dotenv>=0.19.0 requests>=2.26.0 pyyaml>=5.4.1 pyautogen>=0.2.0 -gitpython>=3.1.40 pre-commit>=3.5.0 +urlextract>=1.0.0 +beautifulsoup4>=4.9.3 +gitpython +pytest +pytest-cov diff --git a/src/run_response_agent.sh b/run_response_agent.sh similarity index 72% rename from src/run_response_agent.sh rename to run_response_agent.sh index 4918cc6..2854145 100644 --- a/src/run_response_agent.sh +++ b/run_response_agent.sh @@ -13,19 +13,20 @@ while [[ "$#" -gt 0 ]]; do done # Navigate to the script directory -cd "$(dirname "$0")" -echo "Directory: $(pwd)" +# cd "$(dirname "$0")" +# echo "Directory: $(pwd)" # Activate virtual environment if it exists -if [ -f "../venv/bin/activate" ]; then - source ../venv/bin/activate - echo "Virtual environment activated from" $(realpath ../venv/bin/activate) +VENV_ACTIVATE_PATH=$(realpath ./venv/bin/activate) +if [ -f "$VENV_ACTIVATE_PATH" ]; then + source $VENV_ACTIVATE_PATH + echo "Virtual environment activated from" $VENV_ACTIVATE_PATH fi # Run the response_agent.py script in a loop with the specified delay echo "Running response_agent.py with delay of $DELAY seconds" while true; do - python3 response_agent.py + python3 src/response_agent.py echo "Next run in $DELAY seconds" sleep "$DELAY" # Wait for the specified delay before running again done diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents.py b/src/agents.py index 331cbfe..6bb4871 100644 --- a/src/agents.py +++ b/src/agents.py @@ -5,8 +5,8 @@ import autogen import subprocess from autogen import ConversableAgent, AssistantAgent, UserProxyAgent -import bot_tools -from git_utils import ( +from src import bot_tools +from src.git_utils import ( get_github_client, get_repository, get_issue_comments, @@ -17,6 +17,7 @@ import triggers from urlextract import URLExtract + # Get callable tool functions tool_funcs = [] for func in dir(bot_tools): @@ -31,7 +32,6 @@ DO NOT MAKE ANY CHANGES TO THE FILES OR CREATE NEW FILES. Only provide information or suggestions. NEVER ask for user input and NEVER expect it. Return file names that are relevant, and if possible, specific lines where changes can be made. - Instead of listing the whole dir, use read_merged_summary or read_merged_docstrings Reply "TERMINATE" in the end when everything is done. """, "edit_assistant": """You are a helpful GitHub bot that reviews issues and generates appropriate responses. @@ -132,8 +132,9 @@ def create_user_agent(): return user -def create_agent(agent_name: str, llm_config: dict) -> AssistantAgent: +def create_agent(agent_name: str) -> AssistantAgent: """Create and configure the autogen agents""" + from config import llm_config agent = AssistantAgent( name=agent_name, @@ -198,12 +199,23 @@ def generate_prompt( last_comment_str, comments_str, all_comments = parse_comments( repo_name, repo_path, details, issue) + # Add URL content information if available + url_content_str = "" + if 'url_contents' in details and details['url_contents']: + url_content_str = "\nURLs found in issue:\n" + for url, content in details['url_contents'].items(): + # Truncate content preview to avoid extremely long prompts + content_preview = content[:500] + \ + "..." if len(content) > 500 else content + url_content_str += f"\n- URL: {url}\n- Content preview: {content_preview}\n" + boilerplate_text = f""" Repository: {repo_name} Local path: {repo_path} Title: {details['title']} Body: {details['body']} - {last_comment_str} # Focus on addressing this comment + {last_comment_str} + {url_content_str} """ if agent_name == "file_assistant": @@ -213,7 +225,6 @@ def generate_prompt( Generate a helpful and specific response addressing the issue contents. Use the tools you have. Do not ask for user input or expect it. - To find details of files use read_merged_summary or read_merged_docstrings If those are not functioning, use tools like search_for_file to search for .py files, or other tools you have. Return response in format: @@ -238,6 +249,7 @@ def generate_prompt( Do not look for files again. Use the files suggested by the previous agent. Provide code blocks which will address the issue where you can and suggest specific lines in specific files where changes can be made. Try to read the whole file (readfile) to understand context where possible. If file is too large, search for specific functions or classes (get_func_code). If you can't find functions to classes, try reading sets of lines repeatedly (readlines). + If you're unsure about a suggested change, use search_github to find similar code snippets in the repository. Format your output with the following structure: - Summary of user's issues and requests @@ -271,9 +283,9 @@ def generate_prompt( Use the tools you have. Do not ask for user input or expect it. DO NOT SUGGEST CODE EXECUTIONS. Only make code editing suggestions. - To find details of files use read_merged_summary or read_merged_docstrings If those are not functioning, use tools like search_for_file to search for .py files, or other tools you have. Try to read the whole file (readfile) to understand context where possible. If file is too large, search for specific functions or classes (get_func_code). If you can't find functions to classes, try reading sets of lines repeatedly (readlines). + If you're unsure about a suggested change, use search_github to find similar code snippets in the repository. Finish the job by suggesting specific lines in specific files where changes can be made. Previous Response: @@ -304,6 +316,7 @@ def generate_prompt( Include file paths, line numbers, and exact code changes where possible. Format the command in a way that can be parsed by automated tools. First try searching for files to get paths. + If you're unsure about a suggested change, use search_github to find similar code snippets in the repository. Format your output with the following structure: - Summary of user's issues and requests diff --git a/src/bot_tools.py b/src/bot_tools.py index 04fa140..ec89bf2 100644 --- a/src/bot_tools.py +++ b/src/bot_tools.py @@ -1,5 +1,8 @@ """ Tools for the agents to use. + +DO NOT PUT ANYTHING HERE THAT IS NOT SAFE FOR THE AGENT TO USE. +ALSO CAN'T HAVE CALLABLE MODULES. """ import os @@ -10,14 +13,6 @@ token_threshold = 100_000 -# Keep everything but tool calls - - -def is_tool_related( - x: dict,) -> bool: - if 'tool_calls' in x.keys() or x['role'] == 'tool': - return True - def get_local_repo_path(repo_name: str) -> str: """ @@ -39,20 +34,6 @@ def get_local_repo_path(repo_name: str) -> str: return f"Repository {repo_name} not found @ {repo_path}" -def get_tracked_repos() -> str: - """ - Get the tracked repositories - - Returns: - - List of tracked repositories - """ - tracked_repos_path = os.path.join(base_dir, 'config', 'repos.txt') - with open(tracked_repos_path, 'r') as file: - tracked_repos = file.readlines() - tracked_repos = [repo.strip() for repo in tracked_repos] - return tracked_repos - - def search_for_pattern( search_dir: str, pattern: str, @@ -75,129 +56,6 @@ def search_for_pattern( return out -def search_and_replace( - file_path: str, - search_text: str, - replace_text: str, -) -> bool: - """ - Search and replace text in a file - - Inputs: - - file_path : Path to file - - search_text : Text to search for - - replace_text : Text to replace with - - Returns: - - True if successful, False if search_text not found - """ - # make backup - import shutil - import ast - - shutil.copy2(file_path, file_path + '.bak') - - with open(file_path, 'r') as file: - file_data = file.read() - - # Check for exact match - if search_text not in file_data: - print(f"Search text not found in file: {file_path}") - return False - - new_data = file_data.replace(search_text, replace_text) - - with open(file_path, 'w') as file: - file.write(new_data) - - # Check that file is valid - try: - ast.parse(new_data) - except SyntaxError as e: - print('Editing file created a syntax error') - print(f"Syntax error in file: {file_path}") - print(e) - # Restore backup - shutil.copy2(file_path + '.bak', file_path) - os.remove(file_path + '.bak') - return False - - print('Search and replace successful') - return True - - -def modify_lines( - file_path: str, - start_line: int, - end_line: int, - new_lines: str, -) -> bool: - """ - Modify lines in a file - Don't use escape characters - - - Can delete lines by setting new_lines to empty string - - Can add lines by setting start_line = end_line - - Can modify lines by setting start_line != end_line - - Inputs: - - file_path : Path to file - - start_line : Start line - - end_line : End line (inclusive) - - new_lines : New lines to replace with - - Returns: - - True if successful, False otherwise - """ - - assert start_line <= end_line, "Start line must be less than or equal to end line" - - # make backup - import shutil - import ast - - shutil.copy2(file_path, file_path + '.bak') - - with open(file_path, 'r') as file: - lines = file.readlines() - - # Check for exact match - if len(lines) < end_line: - print(f"End line greater than number of lines in file: {file_path}") - return False - - init_lines = lines[:start_line] - end_lines = lines[end_line+1:] - mod_lines = new_lines.split('\n') - mod_lines = [line + '\n' for line in mod_lines] - - lines = init_lines + mod_lines + end_lines - - with open(file_path, 'w') as file: - # file.write("".join(lines)) - file.writelines(lines) - - # Check that file is valid - try: - ast.parse("".join(lines)) - except SyntaxError as e: - print('Editing file created a syntax error') - print('View around modified lines') - print_start = max(0, start_line - 5) - print_end = min(start_line + len(mod_lines) + 5, len(lines)) - print("".join(f"{i:03}: {line}" for i, - line in enumerate(lines[print_start:print_end]))) - print(f"Syntax error in file: {file_path}") - print(e) - # Restore backup - shutil.copy2(file_path + '.bak', file_path) - os.remove(file_path + '.bak') - return False - - print('Modify lines successful') - return True - - def search_for_file( directory: str, filename: str, @@ -343,7 +201,7 @@ def readlines( warning = (f"Selected lines exceed token threshold of {token_threshold}. " f"Showing lines {start_line} to {start_line + n_included} " f"({current_tokens}/{total_tokens} tokens). " - f"Use readlines({filepath}, start_line, end_line) " + f"Use readlines({file_path}, start_line, end_line) " f"to read specific ranges.") data = "".join(included_lines) @@ -352,179 +210,6 @@ def readlines( return data -def git_fetch( - blech_clust_path: str, -) -> str: - """Fetch from git - - Inputs: - - blech_clust_path : Path to blech_clust - - Returns: - - Output from git fetch - """ - cmd_str = f"git -C {blech_clust_path} fetch" - out = os.popen(cmd_str).read() - return out - - -def get_commit_history( - blech_clust_path: str, - max_num: int = 10, -) -> str: - """Get the commit history - - Inputs: - - blech_clust_path : Path to blech_clust - - max_num : Maximum number of commits to show - - Returns: - - Commit history - """ - cmd_str = \ - f"git -C {blech_clust_path} log --graph --pretty=format:'%C(auto)%h%d (%cr) %s' --abbrev-commit" - out = os.popen(cmd_str).read() - out = "\n".join(out.split("\n")[:max_num]) - return out - - -def get_current_git_commit( - blech_clust_path: str, -) -> str: - """Get the current git commit - - Inputs: - - blech_clust_path : Path to blech_clust - - Returns: - - Current commit - """ - cmd_str = f"git -C {blech_clust_path} rev-parse HEAD" - out = os.popen(cmd_str).read() - return out - - -def change_git_commit( - blech_clust_path: str, - commit: str) -> str: - """Change the current git commit - Inputs: - - Commit - """ - # os.system(f"git checkout {commit}") - git_fetch(blech_clust_path) - cmd_str = f"git -C {blech_clust_path} checkout {commit}" - out = os.popen(cmd_str).read() - return out - - -def create_file( - file_path: str, - data: str, -) -> bool: - """Create a file with given data - - Inputs: - - file_path : Path to file - - data : Data to write - - Returns: - - True if successful, False otherwise - """ - try: - with open(file_path, 'w') as file: - file.write(data) - print(f"Data written to file: {file_path}") - return True - except Exception as e: - print(f"Error writing to file: {file_path}") - print(e) - return False - - -def run_python_script( - script_path: str, -) -> str: - """Run a script - - Inputs: - - script_path : Path to script - - Returns: - - Output from script - """ - out = os.popen(f"python {script_path}").read() - return out - - -def run_bash_script( - script_path: str, -) -> str: - """Run a bash script - - Inputs: - - script_path : Path to script - - Returns: - - Output from script - """ - out = os.popen(f"bash {script_path}").read() - return out - - -# def read_merged_docstrings(repo_name: str) -> str: -# """Read the merged docstrings JSON file from a repository -# -# Inputs: -# - repo_name : Name of repository (owner/repo) -# -# Returns: -# - Text containing merged docstrings data -# """ -# # import json -# repo_path = get_local_repo_path(repo_name) -# docstrings_path = os.path.join(repo_path, 'file_index', 'data', 'merged_docstrings.json') -# -# try: -# with open(docstrings_path, 'r') as f: -# return f.read() -# # return json.load(f) -# except FileNotFoundError: -# print(f"Docstrings file not found at: {docstrings_path}") -# return '' -# # return {} -# # except json.JSONDecodeError: -# # print(f"Error decoding JSON from: {docstrings_path}") -# # return {} - -def read_merged_summary(repo_name: str) -> str: - """Read the merged summary JSON file from a repository - - Inputs: - - repo_name : Name of repository (owner/repo) - - Returns: - - Text containing merged summary data - """ - # import json - repo_path = get_local_repo_path(repo_name) - summary_path = os.path.join( - repo_path, 'file_index', 'data', 'merged_summary.json') - - try: - with open(summary_path, 'r') as f: - return f.read() - # return json.load(f) - - except FileNotFoundError: - print(f"Summary file not found at: {summary_path}") - return '' - # return {} - # except json.JSONDecodeError: - # print(f"Error decoding JSON from: {summary_path}") - # return {} - - def get_func_code( module_path: str, func_name: str, @@ -565,3 +250,20 @@ def get_func_code( # Get code for function code = "".join(lines[start_line:end_line]) return code + + +def search_github(query: str) -> str: + """ + Search GitHub for a given query and return code snippets. + + Args: + query: The search query string. + + Returns: + A string containing search results with code snippets. + """ + # Import here to avoid circular imports + sys.path.append(src_dir) + from git_utils import perform_github_search + + return perform_github_search(query) diff --git a/src/branch_handler.py b/src/branch_handler.py index badb9d4..96e827e 100644 --- a/src/branch_handler.py +++ b/src/branch_handler.py @@ -37,27 +37,39 @@ def get_issue_related_branches( related_branches.append((branch_name, url)) except Exception as e: print(f"Error getting related branches: {str(e)}") + # We can't directly log to the issue here as we don't have access to write_issue_response + # This will be caught by the calling function if len(related_branches) == 0: repo = git.Repo(repo_path) - possible_branch_name = f"{issue.number}-{'-'.join(issue.title.lower().split(' '))}" + issue_title_cleaned = issue.title.replace(' ', '-').lower() + # Remove any punctuation from the title + issue_title_cleaned = ''.join( + char for char in issue_title_cleaned if char.isalnum() or char == '-' or char == '_') + possible_branch_name = f"{issue.number}-{issue_title_cleaned}" + + fetched_heads = repo.git.ls_remote('--heads', 'origin').splitlines() # Check local branches for branch in repo.heads: if possible_branch_name in branch.name: related_branches.append((branch.name, False)) + for this_head in fetched_heads: + if possible_branch_name in this_head: + related_branches.append((this_head.split('heads/')[1], True)) + # Check remote branches - for remote in repo.remotes: - for ref in remote.refs: - # Skip HEAD ref - if ref.name.endswith('/HEAD'): - continue - # Remove remote name prefix for comparison - branch_name = ref.name.split('/', 1)[1] - if possible_branch_name in branch_name: - related_branches.append((branch_name, True)) + # for remote in repo.remotes: + # for ref in remote.refs: + # # Skip HEAD ref + # if ref.name.endswith('/HEAD'): + # continue + # # Remove remote name prefix for comparison + # branch_name = ref.name.split('/', 1)[1] + # if possible_branch_name in branch_name: + # related_branches.append((branch_name, True)) os.chdir(orig_dir) return related_branches @@ -119,11 +131,16 @@ def checkout_branch(repo_path: str, branch_name: str, create: bool = False) -> N create: If True, create branch if it doesn't exist """ repo = git.Repo(repo_path) + # Get rid of uncommited local changes + repo.git.clean('-f') if create and branch_name not in repo.heads: repo.create_head(branch_name) print(f"Created branch {branch_name}") repo.git.checkout(branch_name) print(f"Checked out branch {branch_name}") + # Force align branch with remote + repo.git.reset('--hard', f'origin/{branch_name}') + print(f"Branch {branch_name} aligned with remote") def delete_branch(repo_path: str, branch_name: str, force: bool = False) -> None: diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..3aeae0f --- /dev/null +++ b/src/config.py @@ -0,0 +1,33 @@ +""" +Configuration file for the GitHub bot +""" +import os +import random +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Define the LLM configuration +llm_config = { + "model": "gpt-4o", + "api_key": os.getenv('OPENAI_API_KEY'), + "temperature": random.uniform(0, 0.2), +} + +# Define repository names as a dictionary +repo_names = { + "repo1": "owner/repo1", + "repo2": "owner/repo2", + # Add more repositories as needed +} + + +def get_tracked_repos(): + """ + Get list of tracked repositories from the repo_names dictionary + + Returns: + List of repository names in format owner/repo + """ + return list(repo_names.values()) diff --git a/src/git_utils.py b/src/git_utils.py index fbd024f..b4edb82 100644 --- a/src/git_utils.py +++ b/src/git_utils.py @@ -1,11 +1,12 @@ """ Utility functions for interacting with GitHub API """ -from typing import List, Dict, Optional, Tuple +from typing import List, Dict, Optional, Tuple, Union import os import subprocess import git -from branch_handler import ( +import traceback +from src.branch_handler import ( get_issue_related_branches, get_current_branch, checkout_branch, @@ -18,6 +19,7 @@ from github.PullRequest import PullRequest from dotenv import load_dotenv import re +import string def clean_response(response: str) -> str: @@ -25,13 +27,41 @@ def clean_response(response: str) -> str: # Remove TERMINATE flags response = re.sub(r'\bTERMINATE\b', '', response, flags=re.IGNORECASE) - # Remove existing signatures - response = re.sub( - r'\n\n---\n\*This response was automatically generated by blech_bot\*\s*$', '', response) + # Don't remove signatures with model info, as they should be preserved + # Only remove duplicate basic signatures if they exist + basic_signature = r'\n\n---\n\*This response was automatically generated by blech_bot\*\s*$' + if response.count(basic_signature) > 1: + # Keep only the first occurrence + parts = re.split(basic_signature, response) + response = parts[0] + basic_signature + ''.join(parts[1:]) return response.strip() +def add_signature_to_comment(comment_text: str, model: str) -> str: + """ + Add a signature with model information to the comment if not already present. + This function is used for both regular responses and error messages. + + Args: + comment_text: The text content of the comment + model: The model used for generating the response + + Returns: + Comment text with signature added if it was not present + """ + # Define the signature with model information + signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {model}*" + + # Check if the signature is already present (exact match) + if signature not in comment_text: + # Also check for any other signature format + if "\n\n---\n*This response was automatically generated by blech_bot" not in comment_text: + comment_text += signature + + return comment_text + + def get_github_client() -> Github: """Initialize and return authenticated GitHub client""" load_dotenv() @@ -50,15 +80,35 @@ def get_repository(client: Github, repo_name: str) -> Repository: def get_open_issues(repo: Repository) -> List[Issue]: - """Get all open issues from repository""" - return list(repo.get_issues(state='open')) + """Get all open issues and pull requests from repository""" + # This already returns both issues and PRs with the GitHub API + return list(repo.get_issues(state='open', sort='created', direction='asc')) def get_issue_comments(issue: Issue) -> List[IssueComment]: - """Get all comments for a specific issue or pull request""" + """Get all comments for a specific issue or pull request, ignoring Graphite-related comments""" + # Text to identify Graphite-related comments + # ignore_text = "This stack of pull requests is managed by" + ignore_text = "app.graphite.dev" + if isinstance(issue, PullRequest): - return list(issue.get_issue_comments()) - return list(issue.get_comments()) + comments = issue.get_issue_comments() + else: + comments = issue.get_comments() + + # Join comment bodies to deal with puncutation and newlines + comment_bodies = [x.body for x in comments] + # Drop punctuation and newlines + # comments = [x.translate(str.maketrans('', '', string.punctuation + '\n')) for x in comment_bodies] + comment_bodies = [" ".join(x.split()) for x in comment_bodies] + + # Filter out comments containing the ignore_text + filtered_comments = [ + comment for comment, comment_body in zip(comments, comment_bodies) + if ignore_text not in comment_body + ] + + return list(filtered_comments) def create_issue_comment( @@ -101,9 +151,27 @@ def write_issue_response(issue: Issue, response_text: str) -> IssueComment: The created comment """ response_text = clean_response(response_text) - signature = "\n\n---\n*This response was automatically generated by blech_bot*" - full_response = response_text + signature - return create_issue_comment(issue, full_response) + + # Check if response already has a signature with model info + model_signature_pattern = r"\n\n---\n\*This response was automatically generated by blech_bot using model .+\*" + has_model_signature = bool( + re.search(model_signature_pattern, response_text)) + + # Check if response has the basic signature + basic_signature = "\n\n---\n*This response was automatically generated by blech_bot*" + has_basic_signature = basic_signature in response_text + + # Only add signature if no signature exists + if not has_model_signature and not has_basic_signature: + # Import the model info from response_agent if available + try: + from response_agent import llm_config + signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {llm_config['model']}*" + except (ImportError, KeyError): + signature = basic_signature + response_text += signature + + return create_issue_comment(issue, response_text) def iterate_issues(repo: Repository): @@ -167,6 +235,19 @@ def update_repository(repo_path: str) -> None: origin.pull() +def get_pr_branch(pr: PullRequest) -> str: + """ + Get the branch name for a pull request + + Args: + pr: The GitHub pull request to check + + Returns: + The branch name of the pull request + """ + return pr.head.ref + + def get_development_branch(issue: Issue, repo_path: str, create: bool = False) -> str: """ Gets or creates a development branch for an issue @@ -184,66 +265,146 @@ def get_development_branch(issue: Issue, repo_path: str, create: bool = False) - ValueError: If gh CLI is not installed RuntimeError: If multiple branches exist for the issue """ - # Check for existing branches related to this issue - related_branches = get_issue_related_branches(repo_path, issue) - - unique_branches = set([branch_name for branch_name, _ in related_branches]) - branch_dict = {} - for branch_name in unique_branches: - branch_dict[branch_name] = [] - wanted_inds = [i for i, (name, _) in enumerate( - related_branches) if name == branch_name] - for ind in wanted_inds: - branch_dict[branch_name].append(related_branches[ind][1]) - - comments = get_issue_comments(issue) - - if len(branch_dict) > 1: - branch_list = "\n".join( - [f"- {branch_name} : Remote = {is_remote}" - for branch_name, is_remote in branch_dict.items()] - ) - error_msg = f"Found multiple branches for issue #{issue.number}:\n{branch_list}\n" +\ - "Please delete or use existing branches before creating a new one." - if "Found multiple branches" not in comments[-1].body: - write_issue_response(issue, error_msg) - raise RuntimeError(error_msg) - elif len(branch_dict) == 1: - return list(branch_dict.keys())[0] - elif create: - try: - # Change to repo directory - original_dir = os.getcwd() - os.chdir(repo_path) - - # Create branch from issue - result = subprocess.run( - ['gh', 'issue', 'develop', str(issue.number)], - check=True, - capture_output=True, - text=True - ) - - related_branch = get_issue_related_branches( - repo_path, issue) - - # Return to original directory - os.chdir(original_dir) + try: + # Check for existing branches related to this issue + related_branches = get_issue_related_branches(repo_path, issue) + + unique_branches = set( + [branch_name for branch_name, _ in related_branches]) + branch_dict = {} + for branch_name in unique_branches: + branch_dict[branch_name] = [] + wanted_inds = [i for i, (name, _) in enumerate( + related_branches) if name == branch_name] + for ind in wanted_inds: + branch_dict[branch_name].append(related_branches[ind][1]) - return related_branch[0][0] + comments = get_issue_comments(issue) - except FileNotFoundError: - error_msg = "GitHub CLI (gh) not found. Please install it first." - if error_msg not in comments[-1].body: - write_issue_response(issue, error_msg) - raise ValueError(error_msg) - except subprocess.CalledProcessError as e: - error_msg = f"Failed to create development branch: {e.stderr.strip()}" - if "Failed to create" not in comments[-1].body: - write_issue_response(issue, error_msg) + if len(branch_dict) > 1: + branch_list = "\n".join( + [f"- {branch_name} : Remote = {is_remote}" + for branch_name, is_remote in branch_dict.items()] + ) + error_msg = f"Found multiple branches for issue #{issue.number}:\n{branch_list}\n" +\ + "Please delete or use existing branches before creating a new one." + # Import the model info from response_agent if available + try: + from response_agent import llm_config + error_msg_with_signature = add_signature_to_comment( + error_msg, llm_config['model']) + except (ImportError, KeyError): + error_msg_with_signature = error_msg + \ + "\n\n---\n*This response was automatically generated by blech_bot*" + + if len(comments) == 0 or "Found multiple branches" not in comments[-1].body: + write_issue_response(issue, error_msg_with_signature) raise RuntimeError(error_msg) - else: - return None + elif len(branch_dict) == 1: + print(f"Found branch: {list(branch_dict.keys())[0]}") + return list(branch_dict.keys())[0] + elif len(branch_dict) == 0: # Use PR to get branch + pr = get_linked_pr(issue) + if pr: + branch_name = get_pr_branch(pr) + print(f"Found branch from linked PR: {branch_name}") + return branch_name + else: + print("No development branch found") + return None + + if create: + try: + # Change to repo directory + original_dir = os.getcwd() + os.chdir(repo_path) + + # Create branch from issue + result = subprocess.run( + ['gh', 'issue', 'develop', str(issue.number)], + check=True, + capture_output=True, + text=True + ) + + related_branch = get_issue_related_branches( + repo_path, issue) + + # Return to original directory + os.chdir(original_dir) + + print(f"Created branch: {related_branch[0][0]}") + return related_branch[0][0] + + except FileNotFoundError: + error_msg = "GitHub CLI (gh) not found. Please install it first." + # Import the model info from response_agent if available + try: + from response_agent import llm_config + error_msg_with_signature = add_signature_to_comment( + error_msg, llm_config['model']) + except (ImportError, KeyError): + error_msg_with_signature = error_msg + \ + "\n\n---\n*This response was automatically generated by blech_bot*" + + if len(comments) == 0 or error_msg not in comments[-1].body: + write_issue_response(issue, error_msg_with_signature) + + # Make sure to return to original directory before raising exception + if 'original_dir' in locals(): + os.chdir(original_dir) + + raise ValueError(error_msg) + except subprocess.CalledProcessError as e: + error_msg = f"Failed to create development branch: {e.stderr.strip()}" + # Import the model info from response_agent if available + try: + from response_agent import llm_config + error_msg_with_signature = add_signature_to_comment( + error_msg, llm_config['model']) + except (ImportError, KeyError): + error_msg_with_signature = error_msg + \ + "\n\n---\n*This response was automatically generated by blech_bot*" + + if len(comments) == 0 or "Failed to create" not in comments[-1].body: + write_issue_response(issue, error_msg_with_signature) + + # Make sure to return to original directory before raising exception + if 'original_dir' in locals(): + os.chdir(original_dir) + + raise RuntimeError(error_msg) + except Exception as e: + error_msg = f"Unexpected error creating development branch: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + try: + from response_agent import llm_config + error_msg_with_signature = add_signature_to_comment( + error_msg, llm_config['model']) + except (ImportError, KeyError): + error_msg_with_signature = error_msg + \ + "\n\n---\n*This response was automatically generated by blech_bot*" + + write_issue_response(issue, error_msg_with_signature) + + # Make sure to return to original directory before raising exception + if 'original_dir' in locals(): + os.chdir(original_dir) + + raise RuntimeError(error_msg) + + except Exception as e: + # Catch-all for any unexpected errors + error_msg = f"Error in get_development_branch: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + try: + from response_agent import llm_config + error_msg_with_signature = add_signature_to_comment( + error_msg, llm_config['model']) + except (ImportError, KeyError): + error_msg_with_signature = error_msg + \ + "\n\n---\n*This response was automatically generated by blech_bot*" + + write_issue_response(issue, error_msg_with_signature) + raise RuntimeError(error_msg) def create_pull_request(repo_path: str) -> str: @@ -301,7 +462,7 @@ def create_pull_request_from_issue(issue: Issue, repo_path: str) -> str: def push_changes_with_authentication( repo_path: str, # pull_request: PullRequest, - out_thread: IssueComment | PullRequest, + out_thread: Issue | PullRequest, branch_name: Optional[str] = None ) -> Tuple[bool, Optional[str]]: """ @@ -323,44 +484,178 @@ def push_changes_with_authentication( remote = repo.remote(name='origin') repo_url = remote.url - if repo_url.startswith('https://'): - repo_suffix = repo_url.split('github.com/')[-1] - repo_url_with_token = f"https://x-access-token:{token}@github.com/{repo_suffix}" - remote.set_url(repo_url_with_token) - try: + if repo_url.startswith('https://'): + repo_suffix = repo_url.split('github.com/')[-1] + repo_url_with_token = f"https://x-access-token:{token}@github.com/{repo_suffix}" + remote.set_url(repo_url_with_token) + remote.push(refspec=f'{branch_name}:{branch_name}') print(f"Successfully pushed changes to {branch_name}") - success_bool = True - except git.GitCommandError as e: - error_msg = f"Failed to push changes: {e.stderr.strip()}" - if isinstance(out_thread, IssueComment): - write_issue_response(out_thread, error_msg) - elif isinstance(out_thread, PullRequest): - pr_comments = list(out_thread.get_issue_comments()) - if 'Failed to push changes' not in pr_comments[-1].body: - out_thread.create_issue_comment(error_msg) - else: - raise ValueError( - "Invalid output thread type, must be IssueComment or PullRequest") - print(error_msg) - success_bool = False - finally: + + except Exception as e: if repo_url.startswith('https://'): remote.set_url(repo_url) # Reset URL to remove token - if success_bool: - return success_bool, None + raise RuntimeError(f"Failed to push changes: {str(e)}") + + +def is_pull_request(issue_or_pr: Union[Issue, PullRequest]) -> bool: + """ + Check if an object is a pull request + + Args: + issue_or_pr: The GitHub issue or pull request to check + + Returns: + True if the object is a pull request, False otherwise + """ + # return hasattr(issue_or_pr, 'merge_commit_sha') + return 'pull' in issue_or_pr.html_url + + +def update_self_repo( + repo_path: str, +) -> bool: + """ + Pull latest changes for the bot's own repository, handling tracked config files. + + Args: + repo_path: Path to the bot's local git repository + """ + import git + import os + import shutil + + git_repo = git.Repo(repo_path) + origin = git_repo.remotes.origin + # Get repo username/repo_name + url_splits = git_repo.remotes.origin.url.split('/')[-2:] + repo_basename = url_splits[-1].split('.')[0] + repo_name = url_splits[-2] + '/' + repo_basename + + # Initialize GitHub client + client = get_github_client() + github_repo = get_repository(client, repo_name) + # Determine the default branch + default_branch = github_repo.default_branch + + print(f"Updating self-repo {repo_name}...") + + # Backup config/repos.txt + config_repos_path = os.path.join(repo_path, 'config', 'repos.txt') + backup_path = os.path.join(repo_path, 'config', 'repos.txt.backup') + + has_backup = False + if os.path.exists(config_repos_path): + print(f"Backing up {config_repos_path}") + shutil.copy2(config_repos_path, backup_path) + has_backup = True + + # Fetch latest changes + print("Fetching latest changes for self-repo") + origin.fetch() + + # Check if the remote is ahead + local_commit = git_repo.head.commit + remote_commit = None + update_performed = False + try: + remote_commit = origin.refs[default_branch].commit + except AttributeError: + # Try common branch names if default_branch reference fails + for branch_name in ['master', 'main']: + try: + remote_commit = getattr(origin.refs, branch_name).commit + break + except AttributeError: + continue + + if not remote_commit: + print( + f"Could not find {default_branch} or common branches on remote") + return # Exit function if we can't determine remote commit + + if remote_commit and local_commit != remote_commit: + print( + f"Remote is ahead. Local commit: {local_commit.hexsha[:7]}, Remote commit: {remote_commit.hexsha[:7]}") + print( + f"Force pulling latest changes for self-repo from {default_branch} branch.") + + # Hard reset to remote branch + git_repo.git.reset('--hard', f'origin/{default_branch}') + update_performed = True else: - return success_bool, error_msg + print( + f"Self-repo is up-to-date. Current commit: {local_commit.hexsha[:7]}") + + # Restore config/repos.txt + if has_backup: + print(f"Restoring {config_repos_path}") + shutil.copy2(backup_path, config_repos_path) + os.remove(backup_path) + + return update_performed + + +def perform_github_search( + query: str, + max_snippet_length: int = 2000, # lines +) -> str: + """ + Perform a search on GitHub using the provided query and extract code URLs. + + Args: + query: The search query string. + + Returns: + A string containing search results with code snippets. + """ + client = get_github_client() + + try: + # Search for code with the given query + search_results = client.search_code(query=query, language='Python') + + # Limit results to avoid rate limiting (GitHub API has limits) + max_results = 5 + results_str = f"GitHub search results for query: '{query}'\n\n" + + for count, file in enumerate(search_results[:max_results]): + + code_url = file.html_url + repo_name = file.repository.full_name + file_path = file.path + + results_str += f"Result {count}:\n" + results_str += f"Repository: {repo_name}\n" + results_str += f"File: {file_path}\n" + results_str += f"URL: {code_url}\n" + + code_snippet = file.decoded_content.decode('utf-8') + + # Truncate very large files + if len(code_snippet) > max_snippet_length: + code_snippet = code_snippet[:max_snippet_length] + \ + "\n... (content truncated, see full file at URL) ..." + + results_str += f"Code snippet:\n```\n{code_snippet}\n```\n\n" + + if count == 0: + results_str += "No results found for this query." + elif count == max_results: + results_str += f"Note: Results limited to {max_results} items. Refine your query for more specific results." + + return results_str + + except Exception as e: + return f"Error performing GitHub search: {str(e)}" def has_linked_pr(issue: Issue) -> bool: """ Check if an issue has a linked pull request - Args: issue: The GitHub issue to check - Returns: True if the issue has a linked PR, False otherwise """ @@ -373,17 +668,14 @@ def has_linked_pr(issue: Issue) -> bool: # Check if the reference is to a PR if event.source and event.source.type == "PullRequest": return True - return False -def get_linked_pr(issue: Issue) -> PullRequest: +def get_linked_pr(issue: Issue) -> Optional[PullRequest]: """ Get the linked pull request for an issue - Args: issue: The GitHub issue to check - Returns: The linked PullRequest object or None if not found """ diff --git a/src/response_agent.py b/src/response_agent.py index 5ac0968..5d8ba11 100644 --- a/src/response_agent.py +++ b/src/response_agent.py @@ -1,22 +1,30 @@ """ Agent for generating responses to GitHub issues using pyautogen + +3 outcomes types for processing each issue or PR: + 1. Success: Processed successfully and response posted + 2. Skip: Skipped because triggers were not met (e.g., no bot tag, already responded) + 3. Error: An error occurred during processing (e.g., exception thrown) """ -from typing import Optional, Tuple +from config import llm_config +from typing import Optional, Tuple, List, Union -from dotenv import load_dotenv import string import triggers -from agents import ( +import traceback +from src.agents import ( create_user_agent, create_agent, generate_prompt, parse_comments ) +from urlextract import URLExtract import agents from autogen import AssistantAgent import bot_tools +import os -from git_utils import ( +from src.git_utils import ( get_github_client, get_repository, write_issue_response, @@ -26,12 +34,15 @@ get_issue_comments, create_pull_request_from_issue, get_development_branch, - has_linked_pr, push_changes_with_authentication, + is_pull_request, + get_pr_branch, + add_signature_to_comment, ) from github.Repository import Repository from github.Issue import Issue -from branch_handler import ( +from github.PullRequest import PullRequest +from src.branch_handler import ( checkout_branch, back_to_master_branch, delete_branch @@ -41,28 +52,180 @@ import os from pprint import pprint from collections.abc import Callable -import random import traceback import json import re from urlextract import URLExtract +import requests +import bs4 +import git load_dotenv() +src_dir = os.path.dirname(os.path.abspath(__file__)) +base_dir = os.path.dirname(src_dir) -api_key = os.getenv('OPENAI_API_KEY') -if not api_key: - raise ValueError("OpenAI API key not found in environment variables") +# Read config/params.json +with open(os.path.join(base_dir, 'config', 'params.json')) as f: + params = json.load(f) -llm_config = { - "model": "gpt-4o", - "api_key": api_key, - "temperature": random.uniform(0, 0.2), -} ############################################################ # Response patterns ############################################################ +def tab_print(x): + """ + Print with tab indentation for readability + """ + """ + Print with tab indentation for readability + :param x: The object to print + """ + if isinstance(x, str): + print('\t' + x) + elif isinstance(x, dict): + pprint(x) + elif isinstance(x, list): + for item in x: + print('\t' + str(item)) + else: + print('\t' + str(x)) + + +def extract_urls_from_issue(issue: Issue) -> List[str]: + """ + Extract URLs from issue body and comments + + Args: + issue: The GitHub issue to extract URLs from + + Returns: + List of URLs found in the issue + """ + extractor = URLExtract() + urls = [] + + # Extract from issue body + issue_body = issue.body or "" + urls.extend(extractor.find_urls(issue_body)) + + # Extract from comments + for comment in get_issue_comments(issue): + comment_body = comment.body or "" + urls.extend(extractor.find_urls(comment_body)) + + # Remove duplicates while preserving order + unique_urls = [] + for url in urls: + if url not in unique_urls: + unique_urls.append(url) + + return unique_urls + + +def scrape_text_from_url(url: str) -> str: + """Scrape text content from a given URL. + + Args: + url: The URL to scrape text from. + + Returns: + The scraped text content or a message if non-text content is detected. + """ + try: + response = requests.get(url, timeout=10) + response.raise_for_status() # Raise an error for bad responses + + # Check if the content type is text-based + content_type = response.headers.get('Content-Type', '') + if 'text' not in content_type and 'html' not in content_type and 'json' not in content_type: + return f"Non-text content detected at URL {url}: {content_type}" + + soup = bs4.BeautifulSoup(response.text, 'html.parser') + + # Remove script and style elements + for script in soup(["script", "style"]): + script.extract() + + # Get text + text = soup.get_text() + + # Break into lines and remove leading and trailing space on each + lines = (line.strip() for line in text.splitlines()) + # Break multi-headlines into a line each + chunks = (phrase.strip() + for line in lines for phrase in line.split(" ")) + # Remove blank lines + text = '\n'.join(chunk for chunk in chunks if chunk) + + return text + except requests.RequestException as e: + tab_print(f"Error fetching URL {url}: {e}") + return f"Error fetching URL {url}: {str(e)}" + + +def summarize_text(text: str, max_length: int = 1000) -> str: + """Summarize text using a summary agent. + + Args: + text: The text to summarize. + max_length: Maximum length of the summary. + + Returns: + The summarized text. + """ + if len(text) <= max_length: + return text + + # Use summary agent to create a contextually relevant summary + summary_agent = create_agent("summary_assistant", llm_config) + + # Create a prompt for the summary agent + summary_prompt = f""" + Please summarize the following text, focusing on the most relevant information. + Maintain all technical details and important context that would be relevant to the issue. + Prioritize code-related information, error messages, and specific technical requirements. + + TEXT TO SUMMARIZE: + {text} + """ + + # Get summary from the agent + summary_results = summary_agent.initiate_chat( + summary_agent, + message=summary_prompt, + max_turns=1, + silent=params['print_llm_output'] + ) + + # Extract the summary from the response + summary = summary_results.chat_history[-1]['content'] + + return summary + + +def get_tracked_repos() -> str: + """ + Get the tracked repositories + + Returns: + - List of tracked repositories + """ + tracked_repos_path = os.path.join(base_dir, 'config', 'repos.txt') + with open(tracked_repos_path, 'r') as file: + tracked_repos = file.readlines() + tracked_repos = [repo.strip() for repo in tracked_repos] + return tracked_repos + +# Keep everything but tool calls + + +def is_tool_related( + x: dict,) -> bool: + if 'tool_calls' in x.keys() or x['role'] == 'tool': + return True + + def check_not_empty(data: str) -> bool: """ Check that given data is not empty and is not a TERMINATE message @@ -75,6 +238,30 @@ def check_not_empty(data: str) -> bool: return False +def clean_response(response: str) -> str: + """ + Remove any existing signatures or TERMINATE flags from response text + + Args: + response: The response text to clean + + Returns: + Cleaned response text without signatures or TERMINATE flags + """ + # Remove TERMINATE flags + response = re.sub(r'\bTERMINATE\b', '', response, flags=re.IGNORECASE) + + # Remove model-specific signatures + model_signature_pattern = r'\n\n---\n\*This response was automatically generated by blech_bot using model .+\*\s*$' + response = re.sub(model_signature_pattern, '', response) + + # Remove basic signatures + basic_signature = r'\n\n---\n\*This response was automatically generated by blech_bot\*\s*$' + response = re.sub(basic_signature, '', response) + + return response.strip() + + def summarize_relevant_comments( issue: Issue, repo_name: str, @@ -104,7 +291,7 @@ def summarize_relevant_comments( } comment_summary_assistant = create_agent( - "comment_summary_assistant", llm_config) + "comment_summary_assistant") summarized_comments = [] for comment in comment_list[:-1]: summary_prompt = generate_prompt( @@ -115,11 +302,14 @@ def summarize_relevant_comments( results_to_summarize=[comment], ) - comment_summary_results = comment_summary_assistant.initiate_chat( - comment_summary_assistant, + chat_config = dict( + recipient=comment_summary_assistant, message=summary_prompt, max_turns=1, + silent=params['print_llm_output'] ) + comment_summary_results = comment_summary_assistant.initiate_chat( + **chat_config) response = comment_summary_results.chat_history[-1]['content'] summarized_comments.append(response) @@ -147,7 +337,7 @@ def summarize_relevant_comments( def generate_feedback_response( issue: Issue, repo_name: str, - max_turns: int = 10, + max_turns: int = 20, ) -> Tuple[str, list]: """Generate an improved response based on user feedback @@ -158,12 +348,28 @@ def generate_feedback_response( Returns: Tuple of (updated response text, full conversation history) """ - print('===============================') - print('Generating feedback response') - print('===============================') + tab_print('===============================') + tab_print('Generating feedback response') + tab_print('===============================') repo_path = bot_tools.get_local_repo_path(repo_name) details = get_issue_details(issue) + # Extract URLs from issue and scrape content + urls = extract_urls_from_issue(issue) + url_contents = {} + + if urls: + tab_print(f"Found {len(urls)} URLs in issue") + for url in urls: + tab_print(f"Scraping content from {url}") + content = scrape_text_from_url(url) + # Summarize content to avoid token limits + summarized_content = summarize_text(content) + url_contents[url] = summarized_content + + # Add URL contents to issue details + details['url_contents'] = url_contents + prompt_kwargs = { "repo_name": repo_name, "repo_path": repo_path, @@ -171,7 +377,7 @@ def generate_feedback_response( "issue": issue, } user = create_user_agent() - feedback_assistant = create_agent("feedback_assistant", llm_config) + feedback_assistant = create_agent("feedback_assistant") comments = get_issue_comments(issue) for comment in reversed(comments): @@ -190,16 +396,14 @@ def generate_feedback_response( feedback_text=feedback_text, ) - feedback_results = user.initiate_chats( - [ - { - "recipient": feedback_assistant, - "message": feedback_prompt, - "max_turns": max_turns, - "summary_method": "reflection_with_llm", - } - ] + chat_config = dict( + recipient=feedback_assistant, + message=feedback_prompt, + max_turns=max_turns, + summary_method="reflection_with_llm", + silent=params['print_llm_output'] ) + feedback_results = user.initiate_chats([chat_config]) for this_chat in feedback_results[0].chat_history[::-1]: this_content = this_chat['content'] @@ -207,8 +411,12 @@ def generate_feedback_response( updated_response = this_content break all_content = [original_response, feedback_text, updated_response] + # Clean the response first to remove any existing signatures + updated_response = clean_response(updated_response) signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {llm_config['model']}*" - return updated_response + signature, all_content + if signature not in updated_response: + updated_response += signature + return updated_response, all_content def generate_new_response( @@ -225,18 +433,34 @@ def generate_new_response( Returns: Tuple of (response text, conversation history) """ - print('===============================') - print('Generating new response') - print('===============================') + tab_print('===============================') + tab_print('Generating new response') + tab_print('===============================') # Get path to repository and issue details repo_path = bot_tools.get_local_repo_path(repo_name) details = get_issue_details(issue) + # Extract URLs from issue and scrape content + urls = extract_urls_from_issue(issue) + url_contents = {} + + if urls: + tab_print(f"Found {len(urls)} URLs in issue") + for url in urls: + tab_print(f"Scraping content from {url}") + content = scrape_text_from_url(url) + # Summarize content to avoid token limits + summarized_content = summarize_text(content) + url_contents[url] = summarized_content + + # Add URL contents to issue details + details['url_contents'] = url_contents + # Create base agents user = create_user_agent() - file_assistant = create_agent("file_assistant", llm_config) - edit_assistant = create_agent("edit_assistant", llm_config) - summary_assistant = create_agent("summary_assistant", llm_config) + file_assistant = create_agent("file_assistant") + edit_assistant = create_agent("edit_assistant") + summary_assistant = create_agent("summary_assistant") # user, file_assistant, edit_assistant = create_agents() # Get prompts and run agents @@ -249,25 +473,27 @@ def generate_new_response( file_prompt = generate_prompt("file_assistant", **prompt_kwargs) edit_prompt = generate_prompt("edit_assistant", **prompt_kwargs) - chat_results = user.initiate_chats( - [ - { - "recipient": file_assistant, - "message": file_prompt, - "max_turns": 10, - "summary_method": "last_msg", - }, - { - "recipient": edit_assistant, - "message": edit_prompt, - "max_turns": 10, - "summary_method": "reflection_with_llm", - }, - ] - ) + chat_configs = [ + dict( + recipient=file_assistant, + message=file_prompt, + max_turns=20, + summary_method="last_msg", + silent=params['print_llm_output'] + ), + dict( + recipient=edit_assistant, + message=edit_prompt, + max_turns=20, + summary_method="reflection_with_llm", + silent=params['print_llm_output'] + ), + ] + + chat_results = user.initiate_chats(chat_configs) results_to_summarize = [ - [x for x in this_result.chat_history if not bot_tools.is_tool_related( + [x for x in this_result.chat_history if not is_tool_related( x)] for this_result in chat_results ] @@ -289,13 +515,18 @@ def generate_new_response( summary_assistant, message=summary_prompt, max_turns=1, + silent=params['print_llm_output'] ) response = summary_results.chat_history[-1]['content'] all_content = results_to_summarize + [response] + # Clean the response first to remove any existing signatures + response = clean_response(response) signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {llm_config['model']}*" - return response + signature, all_content + if signature not in response: + response += signature + return response, all_content def generate_edit_command_response( @@ -313,17 +544,33 @@ def generate_edit_command_response( Returns: Tuple of (response text, conversation history) """ - print('===============================') - print('Generating edit command response') - print('===============================') + tab_print('===============================') + tab_print('Generating edit command response') + tab_print('===============================') # Get path to repository and issue details repo_path = bot_tools.get_local_repo_path(repo_name) details = get_issue_details(issue) + # Extract URLs from issue and scrape content + urls = extract_urls_from_issue(issue) + url_contents = {} + + if urls: + tab_print(f"Found {len(urls)} URLs in issue") + for url in urls: + tab_print(f"Scraping content from {url}") + content = scrape_text_from_url(url) + # Summarize content to avoid token limits + summarized_content = summarize_text(content) + url_contents[url] = summarized_content + + # Add URL contents to issue details + details['url_contents'] = url_contents + user = create_user_agent() generate_edit_command_assistant = create_agent( - "generate_edit_command_assistant", llm_config) + "generate_edit_command_assistant") if summarized_comments: generate_edit_command_prompt = generate_prompt( "generate_edit_command_assistant", @@ -336,26 +583,27 @@ def generate_edit_command_response( repo_name, repo_path, details, issue ) - chat_results = user.initiate_chats( - [ - { - "recipient": generate_edit_command_assistant, - "message": generate_edit_command_prompt, - "max_turns": 10, - "summary_method": "reflection_with_llm", - }, - ] + chat_config = dict( + silent=params['print_llm_output'], + recipient=generate_edit_command_assistant, + message=generate_edit_command_prompt, + max_turns=20, + summary_method="reflection_with_llm", ) + chat_results = user.initiate_chats([chat_config]) - # response = chat_results[0].chat_history[-1]['content'] for this_chat in chat_results[0].chat_history[::-1]: this_content = this_chat['content'] if check_not_empty(this_content): response = this_content break all_content = [response] + # Clean the response first to remove any existing signatures + response = clean_response(response) signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {llm_config['model']}*" - return response + signature, all_content + if signature not in response: + response += signature + return response, all_content ############################################################ # Processing logic @@ -373,13 +621,13 @@ def check_triggers(issue: Issue) -> str: The trigger phrase found in the issue """ if triggers.has_generate_edit_command_trigger(issue): - print('Triggered by generate_edit_command') + tab_print('Triggered by generate_edit_command') return "generate_edit_command" elif triggers.has_user_feedback(issue): - print('Triggered by user feedback') + tab_print('Triggered by user feedback') return "feedback" elif not triggers.has_bot_response(issue): - print('Triggered by new issue') + tab_print('Triggered by new issue') return "new_response" else: return None @@ -404,212 +652,421 @@ def response_selector(trigger: str) -> Callable: return None -def process_issue( - issue: Issue, - repo_name: str, -) -> Tuple[bool, Optional[str]]: +def write_pr_comment( + pr_obj: PullRequest, + response: str, + aider_output: str, + llm_config: dict, + write_str: str = None, +) -> None: """ - Process a single issue - check if it needs response and generate one + Write a comment on the pull request with the generated response and aider output Args: - issue: The GitHub issue to process - - Returns: - Tuple of (whether response was posted, optional error message) + pr_obj: The PullRequest object to write the comment on + response: The generated response text + aider_output: The output from the aider command + llm_config: The configuration for the LLM used to generate the response """ - print(f"Processing issue #{issue.number}") + # Clean the response first to remove any existing signatures + write_str = clean_response(response) + signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {llm_config['model']}*" + if signature not in write_str: + write_str += signature + pr_obj.create_issue_comment(write_str) + + +def develop_issue_flow( + issue_or_pr: Union[Issue, PullRequest], + repo_name: str, + is_pr: bool = False, +) -> Tuple[bool, Optional[str]]: + # Only issues can be developed, not PRs + if is_pr: + return False, "Cannot develop a PR, only issues can be developed" + + tab_print('Triggered by [ develop_issue ] command') + repo_path = bot_tools.get_local_repo_path(repo_name) + + # Check for existing branches + branch_name = get_development_branch( + issue_or_pr, repo_path, create=False) + + # Check if issue has label "under_development" + if "under_development" in [label.name for label in issue_or_pr.labels]: + return False, f"Issue #{issue_or_pr.number} is already under development" + + # First generate edit command from previous discussion + response, _ = generate_edit_command_response( + issue_or_pr, repo_name) + + branch_name = get_development_branch( + issue_or_pr, repo_path, create=True) + original_dir = os.getcwd() + os.chdir(repo_path) + checkout_branch(repo_path, branch_name, create=False) + try: - # Check if issue has blech_bot tag or blech_bot in title, and no existing response - has_bot_mention = triggers.has_blech_bot_tag( - issue) or "[ blech_bot ]" in issue.title.lower() - if not has_bot_mention: - return False, "Issue does not have blech_bot tag or mention in title" - already_responded = triggers.has_bot_response( - issue) and not triggers.has_user_feedback(issue) - pr_comment_bool, pr_comment = triggers.has_pr_creation_comment(issue) - if already_responded and not pr_comment_bool: - return False, "Issue already has a bot response without feedback from user" - - # Check for user comments on PR first - if pr_comment_bool: - - repo_path = bot_tools.get_local_repo_path(repo_name) - repo = get_repository(get_github_client(), repo_name) - - # Get latest user comment - extractor = URLExtract() - urls = extractor.find_urls(pr_comment)[0] - pr_number = int(urls.split('/')[-1]) - pr = repo.get_pull(pr_number) - - comments = list(pr.get_issue_comments()) - # find the latest bot comment - latest_bot_idx = -1 - for i, comment in enumerate(comments): - if "generated by blech_bot" in comment.body: - latest_bot_idx = i - - # check if there are any comments after the latest bot comment - user_feedback_bool = latest_bot_idx >= 0 and latest_bot_idx < len( - comments) - 1 - - branch_name = get_development_branch( - issue, repo_path, create=False) - - # Only run if branch exists and user comment is found on PR - if branch_name and user_feedback_bool: - user_comment = comments[-1].body - print('Triggered by user comment on PR') - - try: - original_dir = os.getcwd() - os.chdir(repo_path) - # Switch to development branch - checkout_branch(repo_path, branch_name, create=False) - - # Might have to pull and merge here if pre-commit hooks are enabled - # and made changes - remote_branch = f"origin/{branch_name}" - subprocess.run( - ['git', 'pull', 'origin', branch_name], check=True) - - if user_comment: - # Summarize relevant comments - summarized_comments, comment_list, summary_comment_str = summarize_relevant_comments( - issue, repo_name) - - if summary_comment_str == '': - summary_comment_str = 'No relevant comments found' - - # Pass to generate_edit_command agent first - response, _ = generate_edit_command_response( - issue, repo_name, summary_comment_str) - - # Then run aider with the generated command - aider_output = run_aider(response, repo_path) - - # Push changes - push_success, err_msg = push_changes_with_authentication( - repo_path, - pr, - branch_name) - - if not push_success: - return False, f"Failed to push changes: {err_msg}" - - # Write response - write_str = f"Applied changes based on comment:\n
View Aider Output\n\n```\n{aider_output}\n```\n
" - signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {llm_config['model']}*" - pr.create_issue_comment(write_str+signature) - - # Clean up - back_to_master_branch(repo_path) - - # Return to original directory - os.chdir(original_dir) - - return True, None - - except Exception as e: - # Clean up on error - back_to_master_branch(repo_path) - # Return to original directory - os.chdir(original_dir) - raise RuntimeError( - f"Failed to process PR comment: {str(e)}") - - # Check for develop_issue trigger next - elif triggers.has_develop_issue_trigger(issue): - print('Triggered by [ develop_issue ] command') - repo_path = bot_tools.get_local_repo_path(repo_name) - - # Check for existing branches - branch_name = get_development_branch( - issue, repo_path, create=False) - # if branch_name is not None: - # return False, f"Branch {branch_name} already exists for issue #{issue.number}" - - # Check for linked PRs - if has_linked_pr(issue): - return False, f"Issue #{issue.number} already has a linked pull request" - - # Check if issue has label "under_development" - if "under_development" in [label.name for label in issue.labels]: - return False, f"Issue #{issue.number} is already under development" - - # First generate edit command from previous discussion - response, _ = generate_edit_command_response(issue, repo_name) - - branch_name = get_development_branch(issue, repo_path, create=True) + # Run aider with the generated command + aider_output = run_aider(response, repo_path) + + # Get repo object and pull request + client = get_github_client() + repo = get_repository(client, repo_name) + + # Push changes with authentication + push_changes_with_authentication( + repo_path, + issue_or_pr, + branch_name + ) + + pr_url = create_pull_request_from_issue(issue_or_pr, repo_path) + pr_number = pr_url.split('/')[-1] + pull = repo.get_pull(int(pr_number)) + + # Create pull request + write_issue_response( + issue_or_pr, + f"Created pull request: {pr_url}\nContinue discussion there." + ) + + # Mark issue with label "under_development" + issue_or_pr.add_to_labels("under_development") + + # write_issue_response(issue, "Generated edit command:\n" + response) + write_str = f"Generated edit command:\n---\n{response}\n\n" + \ + f"Aider output:\n
View Aider Output\n\n```{aider_output}```\n
" + write_pr_comment( + pull, + write_str, + aider_output=aider_output, + llm_config=llm_config, + write_str=write_str + ) + + # Switch back to main branch + back_to_master_branch(repo_path) + # Return to original directory + os.chdir(original_dir) + + except Exception as e: + # Clean up on error + try: + back_to_master_branch(repo_path) + delete_branch(repo_path, branch_name, force=True) + clean_error_msg = "" + except Exception as cleanup_error: + clean_error_msg = f"ERROR during cleanup: {str(cleanup_error)}" + tab_print(clean_error_msg) + + # Return to original directory + os.chdir(original_dir) + + # Log detailed error to the issue with signature + error_msg = f"ERROR: Failed to process develop issue: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + if clean_error_msg: + error_msg += f"\n\n{clean_error_msg}" + tab_print(f"Error logged to issue: {error_msg}") + raise Exception(error_msg) + + return True, None + + +def respond_pr_comment_flow( + issue_or_pr: Union[Issue, PullRequest], + repo_name: str, + pr_comment: str, +) -> Tuple[bool, Optional[str]]: + + try: + tab_print("Attempting to get PR branch details") + repo_path = bot_tools.get_local_repo_path(repo_name) + repo = get_repository(get_github_client(), repo_name) + + # Get latest user comment + extractor = URLExtract() + urls = extractor.find_urls(pr_comment)[0] + pr_number = int(urls.split('/')[-1]) + pr = repo.get_pull(pr_number) + + # comments = list(pr.get_issue_comments()) + # Use the helper function to get comments to filter graphite comments + comments = get_issue_comments(pr) + + if not comments: + tab_print(f"No comments found on the PR# {pr_number}") + tab_print( + "If PR was generated using `develop_issue`, something went wrong.") + + # find the latest bot comment + latest_bot_idx = -1 + for i, comment in enumerate(comments): + if "generated by blech_bot" in comment.body: + latest_bot_idx = i + + # check if there are any comments after the latest bot comment + user_feedback_bool = latest_bot_idx >= 0 and latest_bot_idx < len( + comments) - 1 + + # branch_name = get_development_branch( + # issue_or_pr, repo_path, create=False) + branch_name = get_pr_branch(pr) + tab_print(f"Found branch name: {branch_name}") + + except Exception as e: + pr_msg = f"ERROR: Failed to process PR {pr_number} comment flow: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + tab_print(pr_msg) + raise Exception(pr_msg) + + # Only run if branch exists and user comment is found on PR + if branch_name and user_feedback_bool: + user_comment = comments[-1].body + tab_print(f'Triggered by user comment on PR #{pr_number}') + + try: original_dir = os.getcwd() os.chdir(repo_path) + # Switch to development branch checkout_branch(repo_path, branch_name, create=False) - try: - # Run aider with the generated command - aider_output = run_aider(response, repo_path) + # Might have to pull and merge here if pre-commit hooks are enabled + # and made changes + remote_branch = f"origin/{branch_name}" + subprocess.run( + ['git', 'pull', 'origin', branch_name], check=True) - # Get repo object and pull request - client = get_github_client() - repo = get_repository(client, repo_name) + if user_comment: + # Summarize relevant comments + summarized_comments, comment_list, summary_comment_str = summarize_relevant_comments( + issue_or_pr, repo_name) - # Push changes with authentication - push_success, err_msg = push_changes_with_authentication( - repo_path, - issue, - branch_name - ) + if summary_comment_str == '': + summary_comment_str = 'No relevant comments found' - pr_url = create_pull_request_from_issue(issue, repo_path) - pr_number = pr_url.split('/')[-1] - pull = repo.get_pull(int(pr_number)) + # Pass to generate_edit_command agent first + response, _ = generate_edit_command_response( + issue_or_pr, repo_name, summary_comment_str) - # Create pull request - write_issue_response( - issue, - f"Created pull request: {pr_url}\nContinue discussion there." + # Then run aider with the generated command + aider_output = run_aider(response, repo_path) + + # Push changes + push_changes_with_authentication( + repo_path, + pr, + branch_name) + + # Write response + write_str = f"Applied changes based on comment:\n
View Aider Output\n\n```\n{aider_output}\n```\n
" + write_pr_comment( + pr, + write_str, + aider_output=aider_output, + llm_config=llm_config, + write_str=write_str ) - # Mark issue with label "under_development" - issue.add_to_labels("under_development") + # Clean up + back_to_master_branch(repo_path) - if not push_success: - return False, f"Failed to push changes: {err_msg}" + # Return to original directory + os.chdir(original_dir) - # write_issue_response(issue, "Generated edit command:\n" + response) - write_str = f"Generated edit command:\n---\n{response}\n\n" + \ - f"Aider output:\n
View Aider Output\n\n```{aider_output}```\n
" - signature = f"\n\n---\n*This response was automatically generated by blech_bot using model {llm_config['model']}*" - full_response = write_str + signature - pull.create_issue_comment(full_response) + return True, None - # Switch back to main branch + except Exception as e: + # Clean up on error + try: back_to_master_branch(repo_path) - # Return to original directory - os.chdir(original_dir) + except Exception as cleanup_error: + tab_print(f"Error during cleanup: {str(cleanup_error)}") + + # Return to original directory + os.chdir(original_dir) + + # Log detailed error to the PR with signature + error_msg = f"Failed to process PR# {pr_number} comment: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + tab_print(f"Error logged to PR: {error_msg}") + raise Exception(error_msg) + else: + # Handle case where there are no user comments + pr_msg = f"No user feedback found to process on the PR #{pr_number}" + tab_print(pr_msg) + return False, pr_msg + + +def standalone_pr_flow( + issue_or_pr: Union[Issue, PullRequest], + repo_name: str, +) -> Tuple[bool, Optional[str]]: + + try: + # Get repo object and pull request + client = get_github_client() + repo = get_repository(client, repo_name) + pr_obj = repo.get_pull(issue_or_pr.number) + branch_name = get_pr_branch(pr_obj) + repo_path = bot_tools.get_local_repo_path(repo_name) + + original_dir = os.getcwd() + os.chdir(repo_path) + checkout_branch(repo_path, branch_name, create=False) + + summarized_comments, comment_list, summary_comment_str = summarize_relevant_comments( + issue_or_pr, repo_name) + if summary_comment_str == '': + summary_comment_str = 'No relevant comments found' - except Exception as e: - # Clean up on error + # First generate edit command from previous discussion + response, _ = generate_edit_command_response( + issue_or_pr, repo_name, summary_comment_str) + + try: + # Run aider with the generated command + aider_output = run_aider(response, repo_path) + + # Push changes with authentication + push_changes_with_authentication( + repo_path, + issue_or_pr, + branch_name + ) + + # write_issue_response(issue, "Generated edit command:\n" + response) + write_str = f"Generated edit command:\n---\n{response}\n\n" + \ + f"Aider output:\n
View Aider Output\n\n```{aider_output}```\n
" + write_pr_comment( + pr_obj, + write_str, + aider_output=aider_output, + llm_config=llm_config, + write_str=write_str + ) + + # Switch back to main branch + back_to_master_branch(repo_path) + # Return to original directory + os.chdir(original_dir) + + except Exception as e: + # Clean up on error + try: back_to_master_branch(repo_path) - delete_branch(repo_path, branch_name, force=True) - # Return to original directory - os.chdir(original_dir) - raise RuntimeError( - f"Failed to process develop issue: {str(e)}") - - return True, None - - # Generate and post response - trigger = check_triggers(issue) - response_func = response_selector(trigger) - if response_func is None: - return False, f"No trigger found for issue #{issue.number}" - response, all_content = response_func(issue, repo_name) - write_issue_response(issue, response) + except Exception as cleanup_error: + tab_print(f"Error during cleanup: {str(cleanup_error)}") + + # Return to original directory + os.chdir(original_dir) + + # Log detailed error to the PR with signature + error_msg = f"Failed to process standalone PR flow: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + tab_print(f"Error logged to PR: {error_msg}") + raise Exception(error_msg) + return True, None except Exception as e: - return False, f"Error processing issue: {traceback.format_exc()}" + # Handle errors in the initial setup + error_msg = f"Failed to initialize standalone PR flow: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + tab_print(f"Error logged to PR: {error_msg}") + raise Exception(error_msg) + + +def process_issue( + issue_or_pr: Union[Issue, PullRequest], + repo_name: str, +) -> Tuple[bool, Optional[str]]: + """ + Process a single issue or PR - check if it needs response and generate one + + Args: + issue_or_pr: The GitHub issue or PR to process + + Returns: + Tuple of (whether response was posted, optional error message) + """ + is_pr = is_pull_request(issue_or_pr) + entity_type = "PR" if is_pr else "issue" + print(f"Processing {entity_type} #{issue_or_pr.number}") + + try: + has_bot_mention = triggers.has_blech_bot_tag(issue_or_pr) \ + or '[ blech_bot ]' in (issue_or_pr.title or '').lower() + if not has_bot_mention: + # This is a skip outcome, not an error + return False, f"{entity_type} #{issue_or_pr.number} does not have blech_bot label" + + # Check if a pr_creation comment exists for the issue + pr_creation_comment_bool, pr_creation_comment = triggers.has_pr_creation_comment( + issue_or_pr) + # Check if already responded without user feedback + already_responded = triggers.has_bot_response( + issue_or_pr) and not triggers.has_user_feedback(issue_or_pr) + if already_responded and not pr_creation_comment_bool: + # This is a skip outcome, not an error + return False, f"{entity_type} already has a bot response without feedback from user" + + has_error = triggers.has_error_comment(issue_or_pr) + + # Skip processing if an error has been reported + if has_error: + return False, f"Error reported in {entity_type} #{issue_or_pr.number}. Skipping further processing." + + # Handle PR differently + if is_pr: + tab_print('Detected as a Pull Request (PR)') + tab_print('Processing standalone PR flow') + + result, err_msg = standalone_pr_flow( + issue_or_pr, + repo_name + ) + return result, err_msg + + else: # It's an issue + + # Process PR Already created from issue + # If PR has been created, respond if it has an unresponded comment + if pr_creation_comment_bool and not has_error: + # respond_pr_comment_flow checks for unresolved comments on PR + tab_print('Checking for comments on PR generated by this issue') + result, err_msg = respond_pr_comment_flow( + issue_or_pr, + repo_name, + pr_creation_comment + ) + return result, err_msg + + # Developing pull request from issue + # Check for develop_issue trigger next + elif triggers.has_develop_issue_trigger(issue_or_pr): + result, err_msg = develop_issue_flow( + issue_or_pr, + repo_name, + is_pr=is_pr + ) + return result, err_msg + + # Process as new issue + else: + # Generate and post response + trigger = check_triggers(issue_or_pr) + response_func = response_selector(trigger) + if response_func is None: + # This is a skip outcome, not an error + return False, f"No trigger found for {entity_type} #{issue_or_pr.number}" + response, all_content = response_func(issue_or_pr, repo_name) + write_issue_response(issue_or_pr, response) + return True, None + except Exception as e: + # This is a true error outcome + error_msg = f"Error processing {entity_type} #{issue_or_pr.number}: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + # Log the error to the issue/PR with signature + write_issue_response(issue_or_pr, add_signature_to_comment( + error_msg, llm_config['model'])) + tab_print(f"Error logged to {entity_type}: {error_msg}") + return False, f"ERROR: {error_msg}" def run_aider(message: str, repo_path: str) -> str: @@ -632,6 +1089,9 @@ def run_aider(message: str, repo_path: str) -> str: original_dir = os.getcwd() os.chdir(repo_path) + # Current commit + current_commit = git.Repo(repo_path).head.object.hexsha + # Run aider with the message result = subprocess.run( ['aider', '--sonnet', '--yes-always', '--message', message], @@ -647,66 +1107,137 @@ def run_aider(message: str, repo_path: str) -> str: text=True ) + # Check if there are any changes + updated_commit = git.Repo(repo_path).head.object.hexsha + if current_commit == updated_commit: + raise RuntimeError("No changes made by Aider") + # Return to original directory os.chdir(original_dir) return result.stdout except FileNotFoundError: - raise ValueError( - "Aider not found. Please install it first with 'pip install aider-chat'") + error_msg = "Aider not found. Please install it first with 'pip install aider-chat'" + os.chdir(original_dir) if 'original_dir' in locals() else None + raise ValueError(error_msg) except subprocess.CalledProcessError as e: - raise RuntimeError(f"Failed to run aider: {e.stderr}") + error_msg = f"Failed to run aider: {e.stderr}" + os.chdir(original_dir) if 'original_dir' in locals() else None + raise RuntimeError(error_msg) + except Exception as e: + error_msg = f"Unexpected error running aider: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + os.chdir(original_dir) if 'original_dir' in locals() else None + raise RuntimeError(error_msg) def process_repository( repo_name: str, ) -> None: """ - Process all open issues in a repository + Process all open issues and PRs in a repository Args: repo_name: Full name of repository (owner/repo) """ - # Initialize GitHub client - client = get_github_client() - repo = get_repository(client, repo_name) + try: + # Initialize GitHub client + client = get_github_client() + repo = get_repository(client, repo_name) - # Get local repository path - repo_dir = bot_tools.get_local_repo_path(repo_name) + # Get local repository path + repo_dir = bot_tools.get_local_repo_path(repo_name) - # Clone repository only if not already present - if not os.path.exists(repo_dir): - repo_dir = clone_repository(repo) + # Clone repository only if not already present + if not os.path.exists(repo_dir): + repo_dir = clone_repository(repo) - # Determine the default branch - default_branch = repo.default_branch + # Determine the default branch + default_branch = repo.default_branch + + # Ensure repository is on the default branch + try: + checkout_branch(repo_dir, default_branch) + except Exception as e: + error_msg = f"Error switching to default branch '{default_branch}': {str(e)}\n\n```\n{traceback.format_exc()}\n```" + tab_print(error_msg) + # We can't log this to an issue since we're processing the whole repository + # But we'll print it for logging purposes + return + + # Update repository + update_repository(repo_dir) + + # Get open issues + open_issues = repo.get_issues(state='open') + + # Process each issue and PR + for item in open_issues: + entity_type = "PR" if is_pull_request(item) else "issue" + + # Process the issue/PR and determine the outcome + success, message = process_issue(item, repo_name) + if success: + # Success outcome + tab_print( + f"Successfully processed {entity_type} #{item.number}") + else: + # Determine if this is a skip or error outcome based on message content + if "ERROR" in message: + # This is an error outcome + tab_print( + f"ERROR processing {entity_type} #{item.number}: {message}") + else: + # This is a skip outcome + tab_print( + f"Skipped {entity_type} #{item.number}: {message}") - # Ensure repository is on the default branch - try: - checkout_branch(repo_dir, default_branch) except Exception as e: - print( - f"Error switching to default branch '{default_branch}': {str(e)}") - return - # Update repository - update_repository(repo_dir) - - # Get open issues - open_issues = repo.get_issues(state='open') - - # Process each issue - for issue in open_issues: - success, error = process_issue(issue, repo_name) - if success: - print(f"Successfully processed issue #{issue.number}") + error_msg = f"Error processing repository {repo_name}: {str(e)}\n\n```\n{traceback.format_exc()}\n```" + tab_print(error_msg) + + +def initialize_bot() -> None: + """ + Initialize the bot and ensure it is up-to-date. + """ + if params['auto_update']: + print('===============================') + print("== Updating bot repository...") + # Path to the bot's own repository + self_repo_path = os.path.dirname( + os.path.dirname(os.path.abspath(__file__))) + + # Update the bot's own repository + from git_utils import update_self_repo + print(f"Updating bot repository at {self_repo_path}") + update_performed = update_self_repo(self_repo_path) + if update_performed: + print("Bot repository update complete") + print("Exiting to apply updates. Please restart the bot.") + print('===============================') + os._exit(0) # Terminate process to allow restart with updates else: - print(f"Skipped issue #{issue.number}: {error}") + print("Bot already up to date") + print('===============================') + else: + print('===============================') + print("Auto-update is disabled. Skipping bot repository update.") + print('===============================') + + if not params['print_llm_output']: + print('===============================') + print("LLM output printing is disabled. Set 'print_llm_output' to true in the parameters to enable.") + print('===============================') if __name__ == '__main__': + # Initialize the bot (self-update) + initialize_bot() + # Get list of repositories to process - tracked_repos = bot_tools.get_tracked_repos() + from config import get_tracked_repos + tracked_repos = get_tracked_repos() print(f'Found {len(tracked_repos)} tracked repositories') pprint(tracked_repos) @@ -717,7 +1248,7 @@ def process_repository( process_repository(repo_name) print(f'Completed processing {repo_name}') except Exception as e: - print(f'Error processing {repo_name}: {str(e)}') + tab_print(f'Error processing {repo_name}: {str(e)}') continue print('\nCompleted processing all repositories') diff --git a/src/triggers.py b/src/triggers.py index d5f1da4..a0ef1cb 100644 --- a/src/triggers.py +++ b/src/triggers.py @@ -1,8 +1,11 @@ """ Functions to check specific conditions """ +import os # noqa: E501 +import sys # noqa: E501 +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # noqa: E501 from github import Issue -from git_utils import get_issue_comments +from src.git_utils import get_issue_comments, has_linked_pr, get_linked_pr def has_blech_bot_tag(issue: Issue) -> bool: @@ -121,6 +124,20 @@ def has_pr_creation_comment(issue: Issue) -> bool: return False, None +def has_error_comment(issue: Issue) -> bool: + """ + Check if an issue has comments indicating an error + + Args: + issue: The GitHub issue to check + + Returns: + True if the issue has error comments, False otherwise + """ + comments = get_issue_comments(issue) + return 'Traceback (most recent call last):' in comments[-1].body if comments else False + + def has_user_comment_on_pr(issue: Issue) -> bool: """ Check if there is a user comment on a pull request that needs processing @@ -131,7 +148,6 @@ def has_user_comment_on_pr(issue: Issue) -> bool: Returns: True if there is a user comment on a PR that needs processing """ - from git_utils import has_linked_pr, get_linked_pr # First check issue comments comments = get_issue_comments(issue) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_triggers.py b/tests/test_triggers.py new file mode 100644 index 0000000..9a4d306 --- /dev/null +++ b/tests/test_triggers.py @@ -0,0 +1,260 @@ +import sys # noqa +import os # noqa + +# # Add the src directory to the path so we can import the modules +# sys.path.insert(0, os.path.abspath( +# os.path.join(os.path.dirname(__file__), '..'))) # noqa + +from src.triggers import ( + has_blech_bot_tag, + has_generate_edit_command_trigger, + has_bot_response, + has_user_feedback, + has_develop_issue_trigger, + has_pull_request_trigger, + has_pr_creation_comment, + has_error_comment, + has_user_comment_on_pr +) +from src.git_utils import has_linked_pr, get_linked_pr +from src.git_utils import get_issue_comments +from unittest.mock import Mock, patch +import unittest + + +class TestTriggers(unittest.TestCase): + + def test_has_blech_bot_tag(self): + # Test with the tag present + mock_label = Mock() + mock_label.name = "blech_bot" + issue_with_tag = Mock() + issue_with_tag.labels = [mock_label] + + # Test with the tag absent + mock_other_label = Mock() + mock_other_label.name = "other_tag" + issue_without_tag = Mock() + issue_without_tag.labels = [mock_other_label] + + # Test with empty labels + issue_empty_labels = Mock() + issue_empty_labels.labels = [] + + self.assertTrue(has_blech_bot_tag(issue_with_tag)) + self.assertFalse(has_blech_bot_tag(issue_without_tag)) + self.assertFalse(has_blech_bot_tag(issue_empty_labels)) + + @patch('src.triggers.get_issue_comments') + def test_has_generate_edit_command_trigger(self, mock_get_comments): + # Test with trigger in comments + mock_comment_with_trigger = Mock() + mock_comment_with_trigger.body = "This is a comment with [ generate_edit_command ]" + mock_get_comments.return_value = [mock_comment_with_trigger] + + self.assertTrue(has_generate_edit_command_trigger(Mock())) + + # Test without trigger in comments + mock_comment_without_trigger = Mock() + mock_comment_without_trigger.body = "This is a regular comment" + mock_get_comments.return_value = [mock_comment_without_trigger] + + self.assertFalse(has_generate_edit_command_trigger(Mock())) + + # Test with empty comments + mock_get_comments.return_value = [] + + self.assertFalse(has_generate_edit_command_trigger(Mock())) + + @patch('src.triggers.get_issue_comments') + def test_has_bot_response(self, mock_get_comments): + # Test with bot response + mock_bot_comment = Mock() + mock_bot_comment.body = "This comment was generated by blech_bot" + mock_get_comments.return_value = [mock_bot_comment] + + self.assertTrue(has_bot_response(Mock())) + + # Test without bot response + mock_user_comment = Mock() + mock_user_comment.body = "This is a user comment" + mock_get_comments.return_value = [mock_user_comment] + + self.assertFalse(has_bot_response(Mock())) + + # Test with empty comments + mock_get_comments.return_value = [] + + self.assertFalse(has_bot_response(Mock())) + + @patch('src.triggers.get_issue_comments') + def test_has_user_feedback(self, mock_get_comments): + # Test with user feedback after bot comment + mock_bot_comment = Mock() + mock_bot_comment.body = "This comment was generated by blech_bot" + mock_user_comment = Mock() + mock_user_comment.body = "This is user feedback" + mock_get_comments.return_value = [mock_bot_comment, mock_user_comment] + + self.assertTrue(has_user_feedback(Mock())) + + # Test without user feedback after bot comment + mock_get_comments.return_value = [mock_bot_comment] + + self.assertFalse(has_user_feedback(Mock())) + + # Test with user comment before bot comment + mock_get_comments.return_value = [mock_user_comment, mock_bot_comment] + + self.assertFalse(has_user_feedback(Mock())) + + # Test with empty comments + mock_get_comments.return_value = [] + + self.assertFalse(has_user_feedback(Mock())) + + @patch('src.triggers.get_issue_comments') + def test_has_develop_issue_trigger(self, mock_get_comments): + # Test with develop_issue trigger in latest comment + mock_comment_with_trigger = Mock() + mock_comment_with_trigger.body = "[ develop_issue ]" + mock_get_comments.return_value = [Mock(), mock_comment_with_trigger] + + self.assertTrue(has_develop_issue_trigger(Mock())) + + # Test with develop_issue trigger not in latest comment + mock_comment_without_trigger = Mock() + mock_comment_without_trigger.body = "Regular comment" + mock_get_comments.return_value = [ + mock_comment_with_trigger, mock_comment_without_trigger] + + self.assertFalse(has_develop_issue_trigger(Mock())) + + # Test with empty comments + mock_get_comments.return_value = [] + + self.assertFalse(has_develop_issue_trigger(Mock())) + + @patch('src.triggers.get_issue_comments') + def test_has_pull_request_trigger(self, mock_get_comments): + # Test with pull_request trigger in latest comment + mock_comment_with_trigger = Mock() + mock_comment_with_trigger.body = "Created pull request" + mock_get_comments.return_value = [Mock(), mock_comment_with_trigger] + + self.assertTrue(has_pull_request_trigger(Mock())) + + # Test with pull_request trigger not in latest comment + mock_comment_without_trigger = Mock() + mock_comment_without_trigger.body = "Regular comment" + mock_get_comments.return_value = [ + mock_comment_with_trigger, mock_comment_without_trigger] + + self.assertFalse(has_pull_request_trigger(Mock())) + + # Test with empty comments + mock_get_comments.return_value = [] + + self.assertFalse(has_pull_request_trigger(Mock())) + + @patch('src.triggers.get_issue_comments') + def test_has_pr_creation_comment(self, mock_get_comments): + # Test with PR creation comment + mock_pr_comment = Mock() + mock_pr_comment.body = "Created pull request #123" + mock_get_comments.return_value = [mock_pr_comment] + + result, comment = has_pr_creation_comment(Mock()) + self.assertTrue(result) + self.assertEqual(comment, "Created pull request #123") + + # Test without PR creation comment + mock_regular_comment = Mock() + mock_regular_comment.body = "Regular comment" + mock_get_comments.return_value = [mock_regular_comment] + + result, comment = has_pr_creation_comment(Mock()) + self.assertFalse(result) + self.assertIsNone(comment) + + # Test with empty comments + mock_get_comments.return_value = [] + + result, comment = has_pr_creation_comment(Mock()) + self.assertFalse(result) + self.assertIsNone(comment) + + @patch('src.triggers.get_issue_comments') + def test_has_error_comment(self, mock_get_comments): + # Test with error comment + mock_error_comment = Mock() + mock_error_comment.body = "Traceback (most recent call last): Error occurred" + mock_get_comments.return_value = [mock_error_comment] + + self.assertTrue(has_error_comment(Mock())) + + # Test without error comment + mock_regular_comment = Mock() + mock_regular_comment.body = "Regular comment" + mock_get_comments.return_value = [mock_regular_comment] + + self.assertFalse(has_error_comment(Mock())) + + # Test with empty comments + mock_get_comments.return_value = [] + + self.assertFalse(has_error_comment(Mock())) + + @patch('src.triggers.get_issue_comments') + @patch('src.triggers.has_linked_pr') + @patch('src.triggers.get_linked_pr') + def test_has_user_comment_on_pr(self, mock_get_linked_pr, mock_has_linked_pr, mock_get_comments): + # Test with user comment on issue + mock_user_comment = Mock() + mock_user_comment.body = "User comment" + mock_get_comments.return_value = [mock_user_comment] + + self.assertTrue(has_user_comment_on_pr(Mock())) + + # Test with only bot comment on issue + mock_bot_comment = Mock() + mock_bot_comment.body = "generated by blech_bot" + mock_get_comments.return_value = [mock_bot_comment] + + # Set up for PR comments check + mock_has_linked_pr.return_value = True + mock_pr = Mock() + mock_get_linked_pr.return_value = mock_pr + + # Test with user comment after bot comment on PR + mock_get_comments.side_effect = [ + [mock_bot_comment], # Issue comments + [mock_bot_comment, mock_user_comment] # PR comments + ] + + self.assertTrue(has_user_comment_on_pr(Mock())) + + # Test with only bot comment on PR + mock_get_comments.side_effect = [ + [mock_bot_comment], # Issue comments + [mock_bot_comment] # PR comments + ] + + self.assertFalse(has_user_comment_on_pr(Mock())) + + # Test with no linked PR + mock_has_linked_pr.return_value = False + mock_get_comments.side_effect = None + mock_get_comments.return_value = [mock_bot_comment] + + self.assertFalse(has_user_comment_on_pr(Mock())) + + # Test with exception in PR comments retrieval + mock_has_linked_pr.return_value = True + mock_get_linked_pr.side_effect = Exception("PR retrieval error") + + self.assertFalse(has_user_comment_on_pr(Mock())) + + +if __name__ == '__main__': + unittest.main()