From c06164d825e8493076f0588ddb435a683457ade1 Mon Sep 17 00:00:00 2001 From: Utkarsh Kumar Sharma Date: Fri, 27 Mar 2026 08:47:26 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20update=20dependencies=20-=20pydantic?= =?UTF-8?q?=5Fcore=202.41.5=E2=86=922.42.0,=20setuptools=2079.0.1=E2=86=92?= =?UTF-8?q?82.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai_dependency_updater.egg-info/PKG-INFO | 19 + ai_dependency_updater.egg-info/SOURCES.txt | 36 + .../dependency_links.txt | 1 + .../entry_points.txt | 4 + ai_dependency_updater.egg-info/requires.txt | 15 + ai_dependency_updater.egg-info/top_level.txt | 1 + build/lib/src/__init__.py | 0 build/lib/src/agents/__init__.py | 3 + build/lib/src/agents/analyzer.py | 475 ++++++++++ build/lib/src/agents/orchestrator.py | 378 ++++++++ build/lib/src/agents/updater.py | 848 ++++++++++++++++++ build/lib/src/api/__init__.py | 0 build/lib/src/api/server.py | 476 ++++++++++ build/lib/src/api/startup.py | 263 ++++++ build/lib/src/callbacks/__init__.py | 0 build/lib/src/callbacks/agent_activity.py | 408 +++++++++ build/lib/src/cli/__init__.py | 0 build/lib/src/cli/diagnose.py | 438 +++++++++ build/lib/src/config/__init__.py | 1 + build/lib/src/config/language_map.py | 220 +++++ build/lib/src/integrations/__init__.py | 0 .../lib/src/integrations/github_mcp_client.py | 496 ++++++++++ .../src/integrations/mcp_server_manager.py | 433 +++++++++ build/lib/src/services/__init__.py | 0 build/lib/src/services/cache.py | 407 +++++++++ build/lib/src/tools/__init__.py | 0 build/lib/src/tools/dependency_ops.py | 496 ++++++++++ build/lib/src/utils/__init__.py | 0 build/lib/src/utils/docker.py | 137 +++ requirements.txt | 2 + 30 files changed, 5557 insertions(+) create mode 100644 ai_dependency_updater.egg-info/PKG-INFO create mode 100644 ai_dependency_updater.egg-info/SOURCES.txt create mode 100644 ai_dependency_updater.egg-info/dependency_links.txt create mode 100644 ai_dependency_updater.egg-info/entry_points.txt create mode 100644 ai_dependency_updater.egg-info/requires.txt create mode 100644 ai_dependency_updater.egg-info/top_level.txt create mode 100644 build/lib/src/__init__.py create mode 100644 build/lib/src/agents/__init__.py create mode 100644 build/lib/src/agents/analyzer.py create mode 100644 build/lib/src/agents/orchestrator.py create mode 100644 build/lib/src/agents/updater.py create mode 100644 build/lib/src/api/__init__.py create mode 100644 build/lib/src/api/server.py create mode 100644 build/lib/src/api/startup.py create mode 100644 build/lib/src/callbacks/__init__.py create mode 100644 build/lib/src/callbacks/agent_activity.py create mode 100644 build/lib/src/cli/__init__.py create mode 100644 build/lib/src/cli/diagnose.py create mode 100644 build/lib/src/config/__init__.py create mode 100644 build/lib/src/config/language_map.py create mode 100644 build/lib/src/integrations/__init__.py create mode 100644 build/lib/src/integrations/github_mcp_client.py create mode 100644 build/lib/src/integrations/mcp_server_manager.py create mode 100644 build/lib/src/services/__init__.py create mode 100644 build/lib/src/services/cache.py create mode 100644 build/lib/src/tools/__init__.py create mode 100644 build/lib/src/tools/dependency_ops.py create mode 100644 build/lib/src/utils/__init__.py create mode 100644 build/lib/src/utils/docker.py create mode 100644 requirements.txt diff --git a/ai_dependency_updater.egg-info/PKG-INFO b/ai_dependency_updater.egg-info/PKG-INFO new file mode 100644 index 0000000..fb61f6a --- /dev/null +++ b/ai_dependency_updater.egg-info/PKG-INFO @@ -0,0 +1,19 @@ +Metadata-Version: 2.4 +Name: ai-dependency-updater +Version: 1.0.0 +Summary: Multi-agent AI system for automated dependency management +Requires-Python: >=3.9 +Requires-Dist: langchain>=0.1.0 +Requires-Dist: langchain-core>=0.1.0 +Requires-Dist: langchain-anthropic>=0.1.0 +Requires-Dist: langgraph>=0.2.0 +Requires-Dist: anthropic>=0.18.0 +Requires-Dist: python-dotenv>=1.0.0 +Requires-Dist: mcp>=1.0.0 +Requires-Dist: fastapi>=0.104.0 +Requires-Dist: uvicorn[standard]>=0.24.0 +Requires-Dist: pydantic>=2.0.0 +Requires-Dist: requests>=2.28.0 +Provides-Extra: dev +Requires-Dist: pytest>=7.0.0; extra == "dev" +Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev" diff --git a/ai_dependency_updater.egg-info/SOURCES.txt b/ai_dependency_updater.egg-info/SOURCES.txt new file mode 100644 index 0000000..936edc6 --- /dev/null +++ b/ai_dependency_updater.egg-info/SOURCES.txt @@ -0,0 +1,36 @@ +README.md +pyproject.toml +ai_dependency_updater.egg-info/PKG-INFO +ai_dependency_updater.egg-info/SOURCES.txt +ai_dependency_updater.egg-info/dependency_links.txt +ai_dependency_updater.egg-info/entry_points.txt +ai_dependency_updater.egg-info/requires.txt +ai_dependency_updater.egg-info/top_level.txt +src/__init__.py +src/agents/__init__.py +src/agents/analyzer.py +src/agents/orchestrator.py +src/agents/updater.py +src/api/__init__.py +src/api/server.py +src/api/startup.py +src/callbacks/__init__.py +src/callbacks/agent_activity.py +src/cli/__init__.py +src/cli/diagnose.py +src/config/__init__.py +src/config/language_map.py +src/integrations/__init__.py +src/integrations/github_mcp_client.py +src/integrations/mcp_server_manager.py +src/services/__init__.py +src/services/cache.py +src/tools/__init__.py +src/tools/dependency_ops.py +src/utils/__init__.py +src/utils/docker.py +tests/test_dependency_analyzer.py +tests/test_dependency_operations.py +tests/test_github_mcp_client.py +tests/test_repository_cache.py +tests/test_smart_dependency_updater.py \ No newline at end of file diff --git a/ai_dependency_updater.egg-info/dependency_links.txt b/ai_dependency_updater.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ai_dependency_updater.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/ai_dependency_updater.egg-info/entry_points.txt b/ai_dependency_updater.egg-info/entry_points.txt new file mode 100644 index 0000000..49ff23e --- /dev/null +++ b/ai_dependency_updater.egg-info/entry_points.txt @@ -0,0 +1,4 @@ +[console_scripts] +dep-diagnose = src.cli.diagnose:main +dep-server = src.api.startup:main +dep-updater = src.agents.orchestrator:main diff --git a/ai_dependency_updater.egg-info/requires.txt b/ai_dependency_updater.egg-info/requires.txt new file mode 100644 index 0000000..1912527 --- /dev/null +++ b/ai_dependency_updater.egg-info/requires.txt @@ -0,0 +1,15 @@ +langchain>=0.1.0 +langchain-core>=0.1.0 +langchain-anthropic>=0.1.0 +langgraph>=0.2.0 +anthropic>=0.18.0 +python-dotenv>=1.0.0 +mcp>=1.0.0 +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +pydantic>=2.0.0 +requests>=2.28.0 + +[dev] +pytest>=7.0.0 +pytest-asyncio>=0.21.0 diff --git a/ai_dependency_updater.egg-info/top_level.txt b/ai_dependency_updater.egg-info/top_level.txt new file mode 100644 index 0000000..85de9cf --- /dev/null +++ b/ai_dependency_updater.egg-info/top_level.txt @@ -0,0 +1 @@ +src diff --git a/build/lib/src/__init__.py b/build/lib/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/agents/__init__.py b/build/lib/src/agents/__init__.py new file mode 100644 index 0000000..3c9ae30 --- /dev/null +++ b/build/lib/src/agents/__init__.py @@ -0,0 +1,3 @@ +from src.agents.analyzer import create_dependency_analyzer_agent +from src.agents.orchestrator import create_main_orchestrator +from src.agents.updater import create_smart_updater_agent diff --git a/build/lib/src/agents/analyzer.py b/build/lib/src/agents/analyzer.py new file mode 100644 index 0000000..70c6eca --- /dev/null +++ b/build/lib/src/agents/analyzer.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +""" +Dependency Analyzer Agent + +Analyzes a repository to identify its package manager, locate dependency files, +and identify outdated dependencies. +""" + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Dict + +from dotenv import load_dotenv +from langchain.agents import create_agent +from langchain_anthropic import ChatAnthropic +from langchain_core.tools import tool + +from src.config import DEFAULT_LLM_MODEL, language_map as LanguageMap + +# Import caching module +from src.services.cache import get_cache + + +def _get_nested(obj, path, default="N/A"): + """Get nested value from dict using dot notation (e.g., 'Update.Version').""" + for key in path.split("."): + if isinstance(obj, dict): + obj = obj.get(key) + else: + return default + if obj is None: + return default + return obj + + +def _parse_text_outdated(stdout: str, package_manager: str = "") -> list: + """Parse text-format outdated output into structured list locally (no LLM needed).""" + results = [] + lines = stdout.strip().splitlines() + + for line in lines: + line = line.strip() + if not line or line.startswith(("-", "=", "Package", "Name", "#")): + continue + + # pip: "package current latest type" + # npm (text): "package current wanted latest location" + # gem: "package (newest N, installed M)" + parts = line.split() + if len(parts) >= 3: + name = parts[0] + # Skip table separator rows + if all(c in "-|+" for c in name): + continue + current = parts[1].strip("()") + latest = parts[2].strip("()") + # npm text has 4+ columns: name current wanted latest + if len(parts) >= 4 and package_manager in ("npm", "yarn", "pnpm"): + latest = parts[3] + results.append({"name": name, "current": current, "latest": latest}) + + return results + + +# Load environment variables from .env file +load_dotenv() + + +@tool +def clone_repository(repo_url: str) -> str: + """ + Clone a git repository to a temporary directory with caching support. + + Args: + repo_url: The URL of the git repository to clone (e.g., https://github.com/owner/repo) + + Returns: + JSON string with status and repository path + """ + try: + cache = get_cache() + + # Check if the repository is already cached + cached_path = cache.get_cached_repository(repo_url) + if cached_path: + # Copy cached repo to temp directory + temp_dir = tempfile.mkdtemp(prefix="dep_analyzer_") + shutil.copytree(cached_path, temp_dir, dirs_exist_ok=True) + + return json.dumps( + { + "status": "success", + "repo_path": temp_dir, + "message": f"Repository loaded from cache to {temp_dir}", + "from_cache": True, + } + ) + + # Clone fresh repository + temp_dir = tempfile.mkdtemp(prefix="dep_analyzer_") + result = subprocess.run( + ["git", "clone", "--depth", "1", repo_url, temp_dir], + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + return json.dumps( + { + "status": "error", + "message": f"Failed to clone repository: {result.stderr}", + } + ) + + # Cache the cloned repository + try: + cache.cache_repository(repo_url, temp_dir) + except Exception as cache_error: + # Don't fail if caching fails + print(f"Warning: Failed to cache repository: {cache_error}") + + return json.dumps( + { + "status": "success", + "repo_path": temp_dir, + "message": f"Repository cloned successfully to {temp_dir}", + "from_cache": False, + } + ) + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error cloning repository: {str(e)}"} + ) + + +@tool +def detect_package_manager(repo_path: Path) -> str: + """ + Detect language, package manager, build command, and outdated dependency command for a repository. + + Returns: + JSON string with keys: + - language + - package_manager + - build_command + - outdated_command + - output_format + - field_map + - skip_when + """ + language_map = LanguageMap.LANGUAGE_PACKAGE_BUILD_MAP + repo_files = {p.name for p in repo_path.rglob("*") if p.is_file()} + + for language, lang_cfg in language_map.items(): + # 1. Detect language + detect_files = lang_cfg.get("detect_files", []) + language_detected = False + + for f in detect_files: + if f in repo_files or any(Path(p).match(f) for p in repo_files): + language_detected = True + break + + if not language_detected: + continue + + # 2. Detect package manager (lockfile priority) + for pm_name, pm_cfg in lang_cfg["package_managers"].items(): + lock_files = pm_cfg.get("lock_files", []) + + if not lock_files: + return json.dumps( + { + "language": language, + "package_manager": pm_name, + "build_command": pm_cfg.get("build"), + "outdated_command": pm_cfg.get("outdated_cmd"), + "output_format": pm_cfg.get("output_format", "text"), + "field_map": pm_cfg.get("field_map", {}), + "skip_when": pm_cfg.get("skip_when", {}), + } + ) + + for lock in lock_files: + if lock in repo_files: + return json.dumps( + { + "language": language, + "package_manager": pm_name, + "build_command": pm_cfg.get("build"), + "outdated_command": pm_cfg.get("outdated_cmd"), + "output_format": pm_cfg.get("output_format", "text"), + "field_map": pm_cfg.get("field_map", {}), + "skip_when": pm_cfg.get("skip_when", {}), + } + ) + + # 3. Fallback to first package manager + pm_name, pm_cfg = next(iter(lang_cfg["package_managers"].items())) + return json.dumps( + { + "language": language, + "package_manager": pm_name, + "build_command": pm_cfg.get("build"), + "outdated_command": pm_cfg.get("outdated_cmd"), + "output_format": pm_cfg.get("output_format", "text"), + "field_map": pm_cfg.get("field_map", {}), + "skip_when": pm_cfg.get("skip_when", {}), + } + ) + + return json.dumps( + { + "language": None, + "package_manager": None, + "build_command": None, + "outdated_command": None, + "output_format": "text", + "field_map": {}, + "skip_when": {}, + } + ) + + +@tool +def read_dependency_file(repo_path: str, file_path: str) -> str: + """ + Read the contents of a dependency file. + + Args: + repo_path: Path to the repository + file_path: Relative path to the dependency file + + Returns: + File contents + """ + try: + full_path = os.path.join(repo_path, file_path) + with open(full_path, "r") as f: + content = f.read() + return content + except Exception as e: + return f"Error reading file: {str(e)}" + + +@tool +def check_outdated_dependencies( + repo_path: str, repo_url: str = "", detected_info: dict = None +) -> str: + """ + Check outdated dependencies for a repo using the previously detected package manager. + + Args: + repo_path: Path to the repository + repo_url: Repository URL for caching (optional) + detected_info: Dict returned from detect_repo_build_info_json function + Should contain: language, package_manager, outdated_command + + Returns: + JSON string with outdated packages info + """ + if not detected_info: + return json.dumps({"status": "error", "message": "Detected info not provided"}) + + outdated_cmd = detected_info.get("outdated_command") + package_manager = detected_info.get("package_manager") + language = detected_info.get("language") + + if not outdated_cmd: + return json.dumps( + { + "status": "error", + "message": f"No outdated command defined for {package_manager}", + } + ) + + repo_path = Path(repo_path).resolve() + original_dir = os.getcwd() + os.chdir(repo_path) + + try: + # Check cache first if repo_url provided + cache = get_cache() + if repo_url: + cached = cache.get_cached_outdated(repo_url) + if cached: + cached["from_cache"] = True + return json.dumps(cached) + + # Run outdated command + result = subprocess.run( + outdated_cmd.split(), capture_output=True, text=True, timeout=120 + ) + + stdout = result.stdout.strip() + outdated_list = [] + + if stdout: + output_format = detected_info.get("output_format", "text") + field_map = detected_info.get("field_map", {}) + + try: + if output_format == "json_dict": + data = json.loads(stdout) + for pkg_key, info in data.items(): + name_field = field_map.get("name", "name") + outdated_list.append( + { + "name": pkg_key + if name_field == "_key" + else info.get(name_field, pkg_key), + "current": info.get( + field_map.get("current", "current"), "N/A" + ), + "latest": info.get( + field_map.get("latest", "latest"), "N/A" + ), + } + ) + + elif output_format == "json_array": + data = json.loads(stdout) + for item in data: + outdated_list.append( + { + "name": item.get(field_map.get("name", "name"), "N/A"), + "current": item.get( + field_map.get("current", "current"), "N/A" + ), + "latest": item.get( + field_map.get("latest", "latest"), "N/A" + ), + } + ) + + elif output_format == "ndjson": + skip_when = detected_info.get("skip_when", {}) + decoder = json.JSONDecoder() + pos = 0 + while pos < len(stdout): + while pos < len(stdout) and stdout[pos] in " \t\n\r": + pos += 1 + if pos >= len(stdout): + break + try: + obj, end_pos = decoder.raw_decode(stdout, pos) + pos = end_pos + except json.JSONDecodeError: + break + + # Apply skip rules from language map + skip = False + for key, val in skip_when.items(): + if val is None and key not in obj: + skip = True + elif val is not None and obj.get(key) == val: + skip = True + if skip: + continue + + outdated_list.append( + { + "name": _get_nested(obj, field_map.get("name", "name")), + "current": _get_nested( + obj, field_map.get("current", "current") + ), + "latest": _get_nested( + obj, field_map.get("latest", "latest") + ), + } + ) + + else: # "text" format โ€” parse locally instead of sending to LLM + outdated_list.extend(_parse_text_outdated(stdout, package_manager)) + + except json.JSONDecodeError: + outdated_list.extend(_parse_text_outdated(stdout, package_manager)) + + result_data = { + "status": "success", + "language": language, + "package_manager": package_manager, + "outdated_count": len(outdated_list), + "outdated_packages": outdated_list, + "from_cache": False, + } + + # Cache results if repo_url provided + if repo_url: + try: + cache.cache_outdated(repo_url, result_data) + except Exception: + pass # ignore caching errors + + return json.dumps(result_data) + + except subprocess.TimeoutExpired: + return json.dumps({"status": "error", "message": "Outdated command timed out"}) + except Exception as e: + return json.dumps( + { + "status": "error", + "message": f"Error checking outdated dependencies: {str(e)}", + } + ) + finally: + os.chdir(original_dir) + + +@tool +def cleanup_repository(repo_path: str) -> str: + """ + Clean up the cloned repository. + + Args: + repo_path: Path to the repository to remove + + Returns: + Confirmation message + """ + try: + if os.path.exists(repo_path) and ( + repo_path.startswith("/tmp/dep_analyzer_") + or repo_path.startswith("/tmp/repo_check_") + ): + shutil.rmtree(repo_path) + return json.dumps( + {"status": "success", "message": f"Cleaned up {repo_path}"} + ) + return json.dumps( + {"status": "error", "message": "Invalid path or path doesn't exist"} + ) + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error during cleanup: {str(e)}"} + ) + + +def create_dependency_analyzer_agent(): + """ + Create the dependency analyzer agent. + """ + tools = [ + clone_repository, + detect_package_manager, + read_dependency_file, + check_outdated_dependencies, + ] + + system_message = """You are a dependency analysis agent. Your job: clone a repo, detect its package manager, and check for outdated dependencies. + +Execute these steps IN ORDER. Do NOT skip steps or add extra steps. + +STEP 1: Clone the repository using clone_repository. +STEP 2: Detect the package manager using detect_package_manager with the repo_path from step 1. +STEP 3: Check outdated dependencies using check_outdated_dependencies with the repo_path and detected_info from step 2. +STEP 4: Return a SHORT JSON summary. Do NOT write a long report. + +IMPORTANT RULES: +- Do NOT clean up or delete the repository. It will be used by the next agent. +- Do NOT call read_dependency_file unless check_outdated_dependencies fails. +- Keep ALL your text responses under 50 words. No explanations, no analysis, no commentary. +- Your final response MUST be ONLY this JSON and nothing else: +{"repo_path": "...", "package_manager": "...", "outdated_count": N, "outdated_packages": [...]}""" + + llm = ChatAnthropic(model=os.getenv("LLM_MODEL_NAME", DEFAULT_LLM_MODEL), temperature=0) + + agent_executor = create_agent(llm, tools, system_prompt=system_message) + + return agent_executor diff --git a/build/lib/src/agents/orchestrator.py b/build/lib/src/agents/orchestrator.py new file mode 100644 index 0000000..597875d --- /dev/null +++ b/build/lib/src/agents/orchestrator.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +Auto Update Dependencies - Main Entry Point + +Complete workflow: +1. Analyze repository for outdated dependencies +2. Apply all updates (including major versions) +3. Test the changes +4. Roll back breaking updates if needed +5. Create PR (success) or Issue (failure) +""" + +import json +import os +import subprocess +import sys +from typing import Optional + +from dotenv import load_dotenv +from langchain.agents import create_agent +from langchain_anthropic import ChatAnthropic +from langchain_core.tools import tool + +# Import sub-agents and tools +from src.agents.analyzer import create_dependency_analyzer_agent +from src.config import DEFAULT_LLM_MODEL +from src.agents.updater import create_smart_updater_agent +from src.callbacks.agent_activity import AgentActivityHandler + +# Load environment variables +load_dotenv() + +# Module-level reference to the current orchestrator handler so child tools +# can register their sub-agent handlers for aggregated cost tracking. +_current_orchestrator_handler: Optional[AgentActivityHandler] = None + + +@tool +def analyze_repository(repo_url: str) -> str: + """ + Analyze a repository to find outdated dependencies. + + Args: + repo_url: URL of the repository to analyze + + Returns: + JSON with analysis results including outdated packages + """ + try: + print(f"\nStep 1: Analyzing repository for outdated dependencies...") + + analyzer_agent = create_dependency_analyzer_agent() + handler = AgentActivityHandler("analyzer") + + if _current_orchestrator_handler: + _current_orchestrator_handler.add_child_handler(handler) + + result = analyzer_agent.invoke( + { + "messages": [ + ( + "user", + f"Analyze this repository for outdated dependencies and return a structured JSON report: {repo_url}", + ) + ] + }, + config={"callbacks": [handler]}, + ) + + final_message = result["messages"][-1] + + return json.dumps( + { + "status": "success", + "repo_url": repo_url, + "analysis": final_message.content, + } + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error analyzing repository: {str(e)}"} + ) + + +@tool +def smart_update_and_test( + repo_path: str, outdated_packages: str, package_manager: str +) -> str: + """ + Apply updates, test them, roll back if needed, and create PR or Issue. + + Args: + repo_path: Path to the cloned repository + outdated_packages: JSON string with outdated packages + package_manager: Package manager type (npm, pip, cargo, etc.) + + Returns: + JSON with final result (PR URL or Issue URL) + """ + try: + print(f"\nStep 2: Applying updates and testing...") + + updater_agent = create_smart_updater_agent() + handler = AgentActivityHandler("updater") + + if _current_orchestrator_handler: + _current_orchestrator_handler.add_child_handler(handler) + + result = updater_agent.invoke( + { + "messages": [ + ( + "user", + f"""Update and test dependencies for repository at {repo_path}. + +Outdated packages: {outdated_packages} +Package manager: {package_manager} + +Workflow: +1. Apply ALL updates (including major versions) +2. Run build/test commands +3. If tests fail: identify problematic package and rollback its major update +4. Retry up to 3 times +5. If successful: create GitHub PR with changes +6. If still failing: create GitHub Issue with details + +Return the final PR URL or Issue URL.""", + ) + ] + }, + config={"callbacks": [handler], "recursion_limit": 50}, + ) + + final_message = result["messages"][-1] + + # Extract only essential fields to avoid sending huge payloads to orchestrator LLM + try: + parsed = json.loads(final_message.content) + compact = { + "status": parsed.get("status", "success"), + "url": parsed.get("url", ""), + "message": parsed.get("message", ""), + } + return json.dumps(compact) + except (json.JSONDecodeError, TypeError): + # If not JSON, truncate to avoid bloating the orchestrator context + content = final_message.content + if len(content) > 500: + content = content[:500] + return json.dumps({"status": "success", "result": content}) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error in smart update: {str(e)}"} + ) + + +def validate_prerequisites() -> tuple[bool, str]: + """ + Validate that all prerequisites are met for running the dependency updater. + + Returns: + tuple: (is_valid: bool, message: str) + """ + # Check for Docker + try: + docker_check = subprocess.run( + ["docker", "--version"], capture_output=True, text=True, timeout=10 + ) + if docker_check.returncode != 0: + return ( + False, + "Docker is not available. Please install Docker from https://docs.docker.com/get-docker/", + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return ( + False, + "Docker is not available. Please install Docker from https://docs.docker.com/get-docker/", + ) + + # Check for GitHub Personal Access Token + github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + if not github_token: + return False, ( + "GITHUB_PERSONAL_ACCESS_TOKEN not set. " + "Please set your GitHub token: export GITHUB_PERSONAL_ACCESS_TOKEN='your_token_here'. " + "Create a token at: https://github.com/settings/tokens (Required scopes: repo, workflow)" + ) + + # Check for Anthropic API key + anthropic_key = os.getenv("ANTHROPIC_API_KEY") + if not anthropic_key: + return False, ( + "ANTHROPIC_API_KEY not set. " + "Please set your Anthropic API key: export ANTHROPIC_API_KEY='your_key_here'" + ) + + return True, "All prerequisites validated successfully" + + +def create_main_orchestrator(): + """ + Create the main orchestrator agent that coordinates the entire workflow. + """ + tools = [analyze_repository, smart_update_and_test] + + system_message = """You are the orchestrator for automated dependency updates. Follow this EXACT workflow: + +STEP 1: Call analyze_repository with the repository URL. +- Extract from the result: repo_path, package_manager, and the outdated_packages list. +- If no outdated dependencies, inform the user and stop. + +STEP 2: Call smart_update_and_test with repo_path, package_manager, and outdated_packages from step 1. +- This tool handles: updating deps, building, testing, creating PR or Issue. + +STEP 3: Return the result as a JSON object. Extract the URL from smart_update_and_test's result. +- Your final response MUST be ONLY this JSON and nothing else: + {"status": "pr_created", "url": ""} or + {"status": "issue_created", "url": ""} or + {"status": "up_to_date", "message": "..."} or + {"status": "error", "message": "..."} + +IMPORTANT RULES: +- Do NOT call any other tools. Only use analyze_repository and smart_update_and_test. +- Pass the repo_path from analyze_repository directly to smart_update_and_test. +- Keep ALL your text responses under 50 words. No analysis, no reports, no summaries of intermediate results. +- When calling smart_update_and_test, pass the outdated_packages as a compact JSON string โ€” do NOT reformat or annotate them.""" + + llm = ChatAnthropic(model=os.getenv("LLM_MODEL_NAME", DEFAULT_LLM_MODEL), temperature=0) + + agent_executor = create_agent(llm, tools, system_prompt=system_message) + + return agent_executor + + +def main(): + """ + Main entry point for the automated dependency update system. + """ + if len(sys.argv) < 2: + print(""" +Auto Update Dependencies Tool + +Intelligently updates dependencies with automated testing and rollback. + +Usage: python -m src.agents.orchestrator + +Examples: + python -m src.agents.orchestrator https://github.com/owner/repo + python -m src.agents.orchestrator owner/repo + +What it does: + 1. Analyzes your repo for outdated dependencies + 2. Updates ALL dependencies to latest (including major versions) + 3. Tests the changes (build, test, lint) + 4. Rolls back breaking updates if tests fail + 5. Creates PR if successful + 6. Creates Issue if updates can't be applied safely + +Prerequisites: + - Docker installed and running + - GITHUB_PERSONAL_ACCESS_TOKEN environment variable set + - ANTHROPIC_API_KEY environment variable set + - Git configured with push access to the repository +""") + sys.exit(1) + + repo_input = sys.argv[1] + + # Convert owner/repo to full URL if needed + if not repo_input.startswith("http"): + repo_url = f"https://github.com/{repo_input}" + repo_name = repo_input + else: + repo_url = repo_input + parts = repo_url.rstrip("/").split("/") + repo_name = f"{parts[-2]}/{parts[-1]}" + + print("=" * 80) + print(" Automated Dependency Update System") + print("=" * 80) + print() + print(f"Repository: {repo_name}") + print(f"URL: {repo_url}") + print() + print("Starting automated update process...") + print() + + # Check prerequisites + print("Checking prerequisites...") + is_valid, message = validate_prerequisites() + + if not is_valid: + print(f"\n{message}\n") + sys.exit(1) + + print("All prerequisites validated") + print() + + # Create and run orchestrator + global _current_orchestrator_handler + orchestrator = create_main_orchestrator() + handler = AgentActivityHandler("orchestrator") + _current_orchestrator_handler = handler + + try: + result = orchestrator.invoke( + { + "messages": [ + ( + "user", + f"Automatically update dependencies for repository: {repo_url}", + ) + ] + }, + config={"callbacks": [handler]}, + ) + + print("\n" + "=" * 80) + print(" FINAL RESULT") + print("=" * 80) + print() + + final_message = result["messages"][-1] + try: + result_json = json.loads(final_message.content) + status = result_json.get("status", "unknown") + if status == "pr_created": + print(f" PR Created: {result_json.get('url', 'N/A')}") + elif status == "issue_created": + print(f" Issue Created: {result_json.get('url', 'N/A')}") + elif status == "issue_failed": + print(f" Could not create issue: {result_json.get('message', '')}") + if result_json.get("details"): + print(f"\n Issue details:\n{result_json['details']}") + elif status == "up_to_date": + print( + f" {result_json.get('message', 'All dependencies are up to date.')}" + ) + else: + print(f" Status: {status}") + if result_json.get("message"): + print(f" {result_json['message']}") + except (json.JSONDecodeError, TypeError): + print(final_message.content) + print() + + # Print cost summary + usage = handler.get_usage_summary() + print("=" * 80) + print(" USAGE & COST") + print("=" * 80) + print( + f" Total tokens: {usage['total_tokens']:,} ({usage['input_tokens']:,} in, {usage['output_tokens']:,} out)" + ) + print(f" LLM calls: {usage['llm_calls']}") + print(f" Est. cost: ${usage['estimated_cost_usd']:.4f}") + if usage.get("children"): + for child in usage["children"]: + print( + f" - {child['agent']}: {child['total_tokens']:,} tokens, ${child['estimated_cost_usd']:.4f}" + ) + print() + + except KeyboardInterrupt: + print("\n\nProcess interrupted by user") + sys.exit(1) + except Exception as e: + print(f"\nError: {str(e)}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/build/lib/src/agents/updater.py b/build/lib/src/agents/updater.py new file mode 100644 index 0000000..83c0fff --- /dev/null +++ b/build/lib/src/agents/updater.py @@ -0,0 +1,848 @@ +#!/usr/bin/env python3 +""" +Smart Dependency Updater Agent + +Intelligently updates dependencies with automatic testing and rollback capabilities. +Tests updates, identifies breaking changes, and creates PRs or Issues accordingly. +""" + +import json +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv +from langchain.agents import create_agent +from langchain_anthropic import ChatAnthropic +from langchain_core.tools import tool + +from src.config import DEFAULT_LLM_MODEL + +# Load environment variables +load_dotenv() + +# Import dependency operation tools +from src.tools.dependency_ops import ( + apply_all_updates, + categorize_updates, + get_latest_version_for_major, + parse_error_for_dependency, + rollback_major_update, +) + +# Stores build/test logs captured by run_build_test for the PR body. +# detect_build_command populates _detected_commands; run_build_test uses it +# to classify each run as "build" or "test" and store the output. +_detected_commands = {"build": None, "test": None} +_build_test_logs = {"build": None, "test": None} +_test_info = {"has_tests": True, "exit_code": None} + +# Patterns that indicate no tests were found in the repo +_NO_TESTS_PATTERNS = [ + "no test files", # go test + "no tests ran", # go test + "no tests collected", # pytest + "collected 0 items", # pytest + "no test suites found", # jest + "0 specs, 0 failures", # rspec + "no tests found", # generic + "0 passing", # mocha with no tests +] + + +@tool +def detect_build_command(repo_path: str) -> str: + """ + Auto-detect build, test, and verification commands for the repository. + + Args: + repo_path: Path to the repository + + Returns: + JSON with detected commands for build, test, and verification + """ + try: + commands = { + "package_manager": None, + "install": None, + "build": None, + "test": None, + "lint": None, + "type_check": None, + } + + # JavaScript/TypeScript - npm/yarn/pnpm + if os.path.exists(os.path.join(repo_path, "package.json")): + with open(os.path.join(repo_path, "package.json"), "r") as f: + package_json = json.load(f) + scripts = package_json.get("scripts", {}) + + # Detect package manager + if os.path.exists(os.path.join(repo_path, "pnpm-lock.yaml")): + pm = "pnpm" + elif os.path.exists(os.path.join(repo_path, "yarn.lock")): + pm = "yarn" + else: + pm = "npm" + + commands["package_manager"] = pm + commands["install"] = f"{pm} install" + + # Detect available scripts + if "build" in scripts: + commands["build"] = f"{pm} run build" + if "test" in scripts: + commands["test"] = f"{pm} test" + if "lint" in scripts: + commands["lint"] = f"{pm} run lint" + if "type-check" in scripts or "typecheck" in scripts: + commands["type_check"] = f"{pm} run type-check" + + # Python - pip/poetry/pipenv + elif os.path.exists(os.path.join(repo_path, "pyproject.toml")): + with open(os.path.join(repo_path, "pyproject.toml"), "r") as f: + content = f.read() + if "[tool.poetry]" in content: + commands["package_manager"] = "poetry" + commands["install"] = "poetry install" + commands["build"] = "poetry install" + commands["test"] = "poetry run pytest" + elif os.path.exists(os.path.join(repo_path, "Pipfile")): + commands["package_manager"] = "pipenv" + commands["install"] = "pipenv install" + commands["build"] = "pipenv install" + commands["test"] = "pipenv run pytest" + elif os.path.exists(os.path.join(repo_path, "requirements.txt")): + commands["package_manager"] = "pip" + commands["install"] = "pip install -r requirements.txt" + commands["build"] = "pip install -r requirements.txt" + commands["test"] = "pytest" + else: + # pyproject.toml without poetry โ€” assume pip + commands["package_manager"] = "pip" + commands["install"] = "pip install ." + commands["build"] = "pip install ." + commands["test"] = "pytest" + + elif os.path.exists(os.path.join(repo_path, "Pipfile")): + commands["package_manager"] = "pipenv" + commands["install"] = "pipenv install" + commands["build"] = "pipenv install" + commands["test"] = "pipenv run pytest" + + elif os.path.exists(os.path.join(repo_path, "requirements.txt")): + commands["package_manager"] = "pip" + commands["install"] = "pip install -r requirements.txt" + commands["build"] = "pip install -r requirements.txt" + commands["test"] = "pytest" + + # Rust - Cargo + elif os.path.exists(os.path.join(repo_path, "Cargo.toml")): + commands["package_manager"] = "cargo" + commands["build"] = "cargo build" + commands["test"] = "cargo test" + commands["lint"] = "cargo clippy" + + # Go + elif os.path.exists(os.path.join(repo_path, "go.mod")): + commands["package_manager"] = "go" + commands["build"] = "go build ./..." + commands["test"] = "go test ./..." + commands["lint"] = "go vet ./..." + + # Ruby + elif os.path.exists(os.path.join(repo_path, "Gemfile")): + commands["package_manager"] = "bundler" + commands["install"] = "bundle install" + commands["test"] = "bundle exec rspec" + + # PHP + elif os.path.exists(os.path.join(repo_path, "composer.json")): + commands["package_manager"] = "composer" + commands["install"] = "composer install" + commands["test"] = "composer test" + + # Store detected commands so run_build_test can classify logs + _detected_commands["build"] = commands.get("build") + _detected_commands["test"] = commands.get("test") + _build_test_logs["build"] = None + _build_test_logs["test"] = None + _test_info["has_tests"] = True + _test_info["exit_code"] = None + + return json.dumps({"status": "success", "commands": commands}) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error detecting build commands: {str(e)}"} + ) + + +@tool +def run_build_test(repo_path: str, command: str, timeout: int = 300) -> str: + """ + Execute a build or test command in the repository. + + Args: + repo_path: Path to the repository + command: Command to execute + timeout: Timeout in seconds (default 300) + + Returns: + JSON with execution results (success/failure, stdout, stderr) + """ + try: + original_dir = os.getcwd() + os.chdir(repo_path) + + result = subprocess.run( + command, shell=True, capture_output=True, text=True, timeout=timeout + ) + + os.chdir(original_dir) + + stdout_tail = result.stdout[-2000:] if result.stdout else "" + stderr_tail = result.stderr[-2000:] if result.stderr else "" + + # Capture logs for build/test commands to include in PR body + combined = (stdout_tail + "\n" + stderr_tail).strip() + log_entry = f"$ {command}\n" + log_entry += combined if combined else f"exit code: {result.returncode}" + if command == _detected_commands.get("build"): + _build_test_logs["build"] = log_entry + if command == _detected_commands.get("test"): + _build_test_logs["test"] = log_entry + _test_info["exit_code"] = result.returncode + # Detect if the repo has no unit tests + combined_lower = combined.lower() + if any(pat in combined_lower for pat in _NO_TESTS_PATTERNS): + _test_info["has_tests"] = False + + return json.dumps( + { + "status": "success", + "command": command, + "exit_code": result.returncode, + "succeeded": result.returncode == 0, + "stdout": stdout_tail, + "stderr": stderr_tail, + } + ) + + except subprocess.TimeoutExpired: + os.chdir(original_dir) + return json.dumps( + { + "status": "error", + "command": command, + "message": f"Command timed out after {timeout} seconds", + } + ) + except Exception as e: + os.chdir(original_dir) + return json.dumps( + { + "status": "error", + "command": command, + "message": f"Error running command: {str(e)}", + } + ) + + +@tool +def write_dependency_file(repo_path: str, file_name: str, content: str) -> str: + """ + Write updated dependency file to the repository. + + Args: + repo_path: Path to the repository + file_name: Name of the file (e.g., package.json, requirements.txt) + content: New file content + + Returns: + Confirmation message + """ + try: + file_path = os.path.join(repo_path, file_name) + with open(file_path, "w") as f: + f.write(content) + + return json.dumps( + { + "status": "success", + "file": file_name, + "path": file_path, + "message": f"Successfully wrote {file_name}", + } + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error writing file: {str(e)}"} + ) + + +def _get_repo_owner_name(repo_path: str) -> tuple: + """Extract owner/repo from git remote URL. Returns (owner, repo) or raises ValueError.""" + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + cwd=repo_path, + ) + if result.returncode != 0: + raise ValueError("Could not determine remote URL") + url = result.stdout.strip() + if "github.com" in url: + parts = url.replace(".git", "").split("/") + return parts[-2], parts[-1] + raise ValueError(f"Not a GitHub URL: {url}") + + +@tool +def git_operations(repo_path: str, operation: str, **kwargs) -> str: + """ + Perform git/GitHub operations via the persistent GitHub MCP server. + + Args: + repo_path: Path to the local repository clone + operation: Operation to perform: + - get_remote_url: Get the remote URL and owner/repo name + - create_branch: Create a branch on GitHub (via MCP) + - push_files: Read locally modified files and push them to GitHub (via MCP). + This replaces commit+push โ€” no local git commit needed. + **kwargs: Additional arguments based on operation: + - branch_name: Branch name for create_branch and push_files + - message: Commit message for push_files + + Returns: + JSON with operation results + """ + # LangChain @tool may pass kwargs as a nested dict under the key "kwargs" + if "kwargs" in kwargs and isinstance(kwargs["kwargs"], dict): + kwargs = kwargs["kwargs"] + + try: + original_dir = os.getcwd() + os.chdir(repo_path) + + if operation == "get_remote_url": + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + ) + os.chdir(original_dir) + + if result.returncode == 0: + url = result.stdout.strip() + # Extract owner/repo from URL + if "github.com" in url: + parts = url.replace(".git", "").split("/") + repo_name = f"{parts[-2]}/{parts[-1]}" + else: + repo_name = url + + return json.dumps( + { + "status": "success", + "operation": "get_remote_url", + "url": url, + "repo_name": repo_name, + } + ) + else: + return json.dumps( + { + "status": "error", + "operation": "get_remote_url", + "message": result.stderr, + } + ) + + elif operation == "create_branch": + os.chdir(original_dir) + try: + owner, repo = _get_repo_owner_name(repo_path) + except ValueError as e: + return json.dumps( + {"status": "error", "operation": "create_branch", "message": str(e)} + ) + + default_branch = ( + f"OrteliusAiBot/dep-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + ) + branch_name = kwargs.get("branch_name", default_branch) + + async def _create_branch(server, o, r, b): + return await server.create_branch(o, r, b) + + result = _run_mcp_call(_create_branch, owner, repo, branch_name) + + if result["status"] == "success": + return json.dumps( + { + "status": "success", + "operation": "create_branch", + "branch_name": branch_name, + } + ) + else: + return json.dumps( + { + "status": "error", + "operation": "create_branch", + "message": result.get("message", "Failed to create branch"), + } + ) + + elif operation == "push_files": + try: + owner, repo = _get_repo_owner_name(repo_path) + except ValueError as e: + os.chdir(original_dir) + return json.dumps( + {"status": "error", "operation": "push_files", "message": str(e)} + ) + + branch_name = kwargs.get("branch_name") or kwargs.get("branch") + message = kwargs.get("message", "chore: update dependencies") + + if not branch_name: + os.chdir(original_dir) + return json.dumps( + { + "status": "error", + "operation": "push_files", + "message": "branch_name is required", + } + ) + + # Detect locally modified files using git diff + diff_result = subprocess.run( + ["git", "diff", "--name-only"], + capture_output=True, + text=True, + ) + # Also check untracked files + untracked_result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, + text=True, + ) + + changed_files = [] + all_paths = set() + for line in ( + (diff_result.stdout + "\n" + untracked_result.stdout) + .strip() + .split("\n") + ): + if line.strip(): + all_paths.add(line.strip()) + + if not all_paths: + os.chdir(original_dir) + return json.dumps( + { + "status": "no_changes", + "operation": "push_files", + "message": "No files were modified. Dependencies may already be up to date.", + } + ) + + # Read content of each changed file + for file_path in all_paths: + full_path = os.path.join(repo_path, file_path) + try: + with open(full_path, "r") as f: + content = f.read() + changed_files.append({"path": file_path, "content": content}) + except (UnicodeDecodeError, FileNotFoundError): + # Skip binary files or deleted files + continue + + if not changed_files: + os.chdir(original_dir) + return json.dumps( + { + "status": "no_changes", + "operation": "push_files", + "message": "No text files were modified.", + } + ) + + os.chdir(original_dir) + + async def _push_files(server, o, r, b, f, m): + return await server.push_files(o, r, b, f, m) + + result = _run_mcp_call( + _push_files, owner, repo, branch_name, changed_files, message + ) + + if result["status"] == "success": + return json.dumps( + { + "status": "success", + "operation": "push_files", + "branch_name": branch_name, + "files_pushed": len(changed_files), + "message": f"Pushed {len(changed_files)} files to {branch_name}", + } + ) + else: + return json.dumps( + { + "status": "error", + "operation": "push_files", + "message": result.get("message", "Failed to push files"), + } + ) + + else: + os.chdir(original_dir) + return json.dumps( + {"status": "error", "message": f"Unknown operation: {operation}"} + ) + + except Exception as e: + try: + os.chdir(original_dir) + except Exception: + pass + return json.dumps( + { + "status": "error", + "operation": operation, + "message": f"Error performing git operation: {str(e)}", + } + ) + + +# Reference to the main event loop (set by FastAPI server before running agents) +_main_event_loop = None + + +def set_main_event_loop(loop): + """Store the main event loop for use by worker threads.""" + global _main_event_loop + _main_event_loop = loop + + +def _run_mcp_call(coro_func, *args): + """ + Run an async MCP call from synchronous @tool context. + + Uses the existing PersistentMCPServer singleton. Schedules the async call + on the main event loop (stored via set_main_event_loop) so it reuses + the existing MCP server connection. + """ + import asyncio + + from src.integrations.mcp_server_manager import PersistentMCPServer + + async def _call(): + server = await PersistentMCPServer.get_instance() + if not server.is_running: + await server.ensure_connected() + return await coro_func(server, *args) + + # Try the stored main loop first (set by FastAPI), then check current thread + loop = _main_event_loop + if loop is None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # Schedule on the main event loop and wait for result + future = asyncio.run_coroutine_threadsafe(_call(), loop) + return future.result(timeout=60) + else: + return asyncio.run(_call()) + + +@tool +def create_github_pr( + repo_name: str, branch_name: str, title: str, body: str, base_branch: str = "main" +) -> str: + """ + Create a GitHub Pull Request using the persistent GitHub MCP server. + + Args: + repo_name: Repository in owner/repo format + branch_name: Source branch for the PR + title: PR title + body: PR description + base_branch: Target branch (default: main) + + Returns: + JSON with PR URL or error + """ + try: + parts = repo_name.split("/") + if len(parts) != 2: + return json.dumps( + {"status": "error", "message": f"Invalid repo format: {repo_name}"} + ) + + # Append build/test logs to PR body automatically + log_section = "\n\n---\n\nAll updates have been tested and verified:\n" + build_log = _build_test_logs.get("build", "")[-1000:] if _build_test_logs.get("build") else "" + test_log = _build_test_logs.get("test", "")[-1000:] if _build_test_logs.get("test") else "" + has_tests = _test_info.get("has_tests", True) + has_test_command = _detected_commands.get("test") is not None + if build_log: + log_section += ( + f"\n:white_check_mark: Build successful\n" + f"
Build logs\n\n" + f"```\n{build_log}\n```\n\n
\n" + ) + if test_log and has_tests: + log_section += ( + f"\n:white_check_mark: Tests passing\n" + f"
Test logs\n\n" + f"```\n{test_log}\n```\n\n
\n" + ) + if build_log or (test_log and has_tests): + # Add merge recommendation + if has_tests: + log_section += ( + "\n:rocket: **This PR is safe to merge.** " + "All dependency updates have been verified with a successful build and passing tests.\n" + ) + elif has_test_command and not has_tests: + log_section += ( + "\n:warning: **No unit tests were found in this repository.** " + "The build succeeded, but there are no tests to verify runtime behavior. " + "Consider adding unit tests to catch potential issues from dependency updates.\n" + "\n:rocket: **This PR can be merged**, but we strongly recommend adding tests for better safety.\n" + ) + else: + log_section += ( + "\n:warning: **No test command is configured for this project.** " + "The build succeeded, but no tests were run. " + "Consider adding unit tests to catch potential runtime issues from dependency updates.\n" + "\n:rocket: **This PR can be merged**, but we strongly recommend adding tests for better safety.\n" + ) + body = body + log_section + + async def _create_pr(server, owner, repo, t, b, head, base): + return await server.create_pull_request( + repo_owner=owner, + repo_name=repo, + title=t, + body=b, + head=head, + base=base, + ) + + result = _run_mcp_call( + _create_pr, parts[0], parts[1], title, body, branch_name, base_branch + ) + + if result["status"] == "success": + # Extract PR URL from various possible response formats + pr_url = "" + data = result.get("data", {}) + if isinstance(data, dict): + pr_url = data.get("html_url", "") + # Some MCP responses nest the URL differently + if not pr_url and "url" in data: + pr_url = data["url"] + # Fallback: pr_url at top level (old client format) + if not pr_url: + pr_url = result.get("pr_url", "") + return json.dumps( + { + "status": "success", + "pr_url": pr_url, + "message": "Successfully created PR", + } + ) + else: + return json.dumps( + {"status": "error", "message": result.get("message", "Unknown error")} + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error creating PR via MCP: {str(e)}"} + ) + + +@tool +def create_github_issue( + repo_name: str, title: str, body: str, labels: str = "dependencies" +) -> str: + """ + Create a GitHub Issue using the persistent GitHub MCP server. + + Args: + repo_name: Repository in owner/repo format + title: Issue title + body: Issue description + labels: Comma-separated labels (default: dependencies) + + Returns: + JSON with Issue URL or error + """ + try: + parts = repo_name.split("/") + if len(parts) != 2: + return json.dumps( + {"status": "error", "message": f"Invalid repo format: {repo_name}"} + ) + + label_list = ( + [l.strip() for l in labels.split(",")] if labels else ["dependencies"] + ) + + async def _create_issue(server, owner, repo, t, b, lbls): + return await server.create_issue( + repo_owner=owner, + repo_name=repo, + title=t, + body=b, + labels=lbls, + ) + + result = _run_mcp_call( + _create_issue, parts[0], parts[1], title, body, label_list + ) + + if result["status"] == "success": + # Extract issue URL from various possible response formats + issue_url = "" + data = result.get("data", {}) + if isinstance(data, dict): + issue_url = data.get("html_url", "") + if not issue_url and "url" in data: + issue_url = data["url"] + if not issue_url: + issue_url = result.get("issue_url", "") + return json.dumps( + { + "status": "success", + "issue_url": issue_url, + "message": "Successfully created issue", + } + ) + else: + return json.dumps( + {"status": "error", "message": result.get("message", "Unknown error")} + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error creating issue via MCP: {str(e)}"} + ) + + +def create_smart_updater_agent(): + """ + Create the smart dependency updater agent with testing and rollback capabilities. + """ + tools = [ + detect_build_command, + run_build_test, + write_dependency_file, + git_operations, + create_github_pr, + create_github_issue, + apply_all_updates, + rollback_major_update, + parse_error_for_dependency, + categorize_updates, + get_latest_version_for_major, + ] + + system_message = """You are a dependency update agent. Follow this EXACT workflow. + +STEP 1: Call detect_build_command with repo_path. + +STEP 2: UPDATE DEPENDENCIES +- npm/yarn/pnpm/pip/poetry: apply_all_updates โ†’ write_dependency_file. +- go: run_build_test with "go get pkg1@v1 pkg2@v2 ... && go mod tidy". +- cargo: run_build_test with "cargo update". + +STEP 3: BUILD AND TEST +- Run build command via run_build_test. Then run test command (skip if none). +- run_build_test is ONLY for: install, build, test, and "go get" commands. NEVER for cat/grep/ls/pip list/go list or any inspection command. + +STEP 4: HANDLE RESULTS +- BUILD+TESTS PASS: + 1. git_operations: create_branch (no branch_name arg โ€” auto-generated) + 2. git_operations: push_files with returned branch_name + commit message. If status="no_changes", return up_to_date. + 3. create_github_pr with repo_name, branch_name, title, body. Body format: + ## This PR contains updated dependencies:\n\n| Package | Type | Update | Change |\n|---------|------|--------|--------|\n| name | pm | major/minor/patch | `old` โ†’ `new` | + Build/test logs are auto-appended. Do NOT add them. + 4. Return PR URL. + +- BUILD FAILS: create_github_issue immediately (no rollbacks). Return Issue URL. + +- TESTS FAIL: parse_error_for_dependency โ†’ rollback (go: "go get pkg@old && go mod tidy"; others: rollback_major_update + write_dependency_file) โ†’ re-run STEP 3. Max 3 retries, then create_github_issue. + +RULES: +- Do NOT call get_remote_url, categorize_updates, or run inspection commands. +- Use "push_files" not "commit"/"push". Do NOT create lock files. +- Keep responses under 50 words. Final response MUST be JSON: + {"status": "pr_created|issue_created|up_to_date|error|issue_failed", "url": "...", "message": "..."}""" + + llm = ChatAnthropic(model=os.getenv("LLM_MODEL_NAME", DEFAULT_LLM_MODEL), temperature=0) + + agent_executor = create_agent(llm, tools, system_prompt=system_message) + + return agent_executor + + +def main(): + """ + Main entry point for testing the smart updater. + """ + if len(sys.argv) < 2: + print("Usage: python -m src.agents.updater ") + print("\nExample:") + print(" python -m src.agents.updater /tmp/cloned_repo") + sys.exit(1) + + repo_path = sys.argv[1] + + print("=" * 80) + print("Smart Dependency Updater Agent") + print("=" * 80) + print(f"\nRepository: {repo_path}\n") + + agent = create_smart_updater_agent() + + try: + result = agent.invoke( + { + "messages": [ + ( + "user", + f"Update dependencies for repository at {repo_path}. Test the updates and create a PR if successful, or an Issue if they break the build.", + ) + ] + }, + config={"recursion_limit": 50}, + ) + + print("\n" + "=" * 80) + print("FINAL RESULT") + print("=" * 80) + final_message = result["messages"][-1] + print(final_message.content) + print("\n") + + except Exception as e: + print(f"\nError: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/build/lib/src/api/__init__.py b/build/lib/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/api/server.py b/build/lib/src/api/server.py new file mode 100644 index 0000000..80e030f --- /dev/null +++ b/build/lib/src/api/server.py @@ -0,0 +1,476 @@ +""" +FastAPI web server for dependency update automation. +Exposes REST endpoints to analyze and update repository dependencies. +""" + +import asyncio +import json +import os +import subprocess +from contextlib import asynccontextmanager +from typing import Any, Dict, Optional + +from dotenv import load_dotenv +from fastapi import BackgroundTasks, FastAPI, HTTPException +from pydantic import BaseModel, Field + +from src.agents.orchestrator import create_main_orchestrator, validate_prerequisites +from src.utils.docker import get_docker_path + +# Load environment variables +load_dotenv() + + +class RepositoryRequest(BaseModel): + """Request model for repository operations""" + + repository: str = Field( + ..., + description="Repository in format 'owner/repo' or full GitHub URL", + example="facebook/react", + ) + github_token: Optional[str] = Field( + None, + description="GitHub Personal Access Token (optional, uses env var if not provided)", + ) + + +class JobResponse(BaseModel): + """Response model for job submissions""" + + job_id: str + status: str + message: str + repository: str + + +class UsageResponse(BaseModel): + """Token usage and cost for a job""" + + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + llm_calls: int = 0 + estimated_cost_usd: float = 0.0 + + +class JobStatusResponse(BaseModel): + """Response model for job status""" + + job_id: str + status: str + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + usage: Optional[UsageResponse] = None + + +# In-memory job storage (use Redis/DB for production) +jobs_storage: Dict[str, Dict[str, Any]] = {} + + +async def start_persistent_mcp_server(): + """ + Start the persistent MCP server. + This keeps the MCP container running for the lifetime of the API server. + """ + from src.integrations.mcp_server_manager import get_mcp_status, start_mcp_server + + success = await start_mcp_server() + + if not success: + status = await get_mcp_status() + print(f" Failed to start persistent MCP server: {status.error_message}") + + return success + + +async def stop_persistent_mcp_server(): + """Stop the persistent MCP server on shutdown.""" + from src.integrations.mcp_server_manager import stop_mcp_server + + print("Stopping persistent MCP server...") + await stop_mcp_server() + print(" Persistent MCP server stopped") + + +async def setup_github_mcp_docker(): + """ + Verify Docker is available and start the persistent MCP server. + Image pulling is handled by startup.py's pull_mcp_image(). + """ + print("Setting up GitHub MCP Docker image...") + docker_cmd = get_docker_path() + + try: + # Check if Docker is available + result = subprocess.run( + [docker_cmd, "--version"], capture_output=True, text=True, timeout=10 + ) + + if result.returncode != 0: + raise RuntimeError("Docker is not available") + + print(f"Docker found: {result.stdout.strip()}") + + # Verify the image exists locally + verify_result = subprocess.run( + [docker_cmd, "images", "ghcr.io/github/github-mcp-server", "-q"], + capture_output=True, + text=True, + timeout=10, + ) + + if not verify_result.stdout.strip(): + raise RuntimeError( + "GitHub MCP server image not available. Run startup with --skip-checks disabled to pull it." + ) + + print("GitHub MCP server image verified") + + # Start the persistent MCP server (keeps running until shutdown) + await start_persistent_mcp_server() + + print("GitHub MCP setup complete") + + except subprocess.TimeoutExpired: + raise RuntimeError("Docker command timed out") + except Exception as e: + raise RuntimeError(f"Failed to setup GitHub MCP Docker: {str(e)}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Application lifespan manager. + Sets up GitHub MCP Docker on startup and cleans up on shutdown. + """ + # Startup + print("Starting Dependency Update API Server...") + + try: + await setup_github_mcp_docker() + print("Server ready to accept requests") + except Exception as e: + print(f"Startup failed: {str(e)}") + print("Server will start but may not function correctly") + + yield + + # Shutdown + print("Shutting down server...") + await stop_persistent_mcp_server() + + +# Create FastAPI app with lifespan +app = FastAPI( + title="Dependency Update Automation API", + description="Automatically analyze and update repository dependencies with intelligent testing and rollback", + version="1.0.0", + lifespan=lifespan, +) + + +async def process_repository_update( + job_id: str, repository: str, github_token: Optional[str] = None +): + """ + Background task to process repository updates. + + Args: + job_id: Unique job identifier + repository: Repository to process + github_token: GitHub token for API operations + """ + try: + # Update job status + jobs_storage[job_id]["status"] = "processing" + + # Validate prerequisites + print(f"[Job {job_id}] Validating prerequisites...") + is_valid, message = validate_prerequisites() + + if not is_valid: + jobs_storage[job_id]["status"] = "failed" + jobs_storage[job_id]["error"] = message + return + + # Set GitHub token if provided + if github_token: + os.environ["GITHUB_PERSONAL_ACCESS_TOKEN"] = github_token + + # Create orchestrator agent + print(f"[Job {job_id}] Creating orchestrator agent...") + agent = create_main_orchestrator() + + # Run the update process with activity logging + from src.callbacks.agent_activity import AgentActivityHandler + + handler = AgentActivityHandler("orchestrator", job_id=job_id) + + # Set module-level handler so child agents register for cost aggregation + import src.agents.orchestrator as orch_module + + orch_module._current_orchestrator_handler = handler + + print(f"[Job {job_id}] Processing repository: {repository}") + + # Run agent.invoke() in a thread so it doesn't block the event loop. + # This is critical: MCP tool calls use run_coroutine_threadsafe() to + # schedule async MCP operations back on this event loop. If the loop + # is blocked by agent.invoke(), it deadlocks. + import asyncio + + from src.agents.updater import set_main_event_loop + + loop = asyncio.get_running_loop() + set_main_event_loop(loop) + result = await loop.run_in_executor( + None, + lambda: agent.invoke( + { + "messages": [ + ( + "user", + f"Analyze and update dependencies for repository: {repository}", + ) + ] + }, + config={"callbacks": [handler]}, + ), + ) + + # Update job with results + usage_summary = handler.get_usage_summary() + final_message = result["messages"][-1].content if result.get("messages") else "" + + # Try to parse structured JSON from orchestrator's final message + parsed_result = {"output": final_message, "repository": repository} + try: + result_json = json.loads(final_message) + parsed_result["status"] = result_json.get("status", "unknown") + if "url" in result_json: + parsed_result["url"] = result_json["url"] + if "message" in result_json: + parsed_result["message"] = result_json["message"] + if "details" in result_json: + parsed_result["details"] = result_json["details"] + except (json.JSONDecodeError, TypeError): + parsed_result["status"] = "completed" + + jobs_storage[job_id]["status"] = "completed" + jobs_storage[job_id]["result"] = parsed_result + jobs_storage[job_id]["activity_log"] = handler.activity_log + jobs_storage[job_id]["usage"] = usage_summary + + print( + f"[Job {job_id}] Completed โ€” Cost: ${usage_summary['estimated_cost_usd']:.4f} " + f"({usage_summary['total_tokens']:,} tokens, {usage_summary['llm_calls']} LLM calls)" + ) + + except Exception as e: + print(f"[Job {job_id}] Failed: {str(e)}") + jobs_storage[job_id]["status"] = "failed" + jobs_storage[job_id]["error"] = str(e) + + +@app.get("/") +async def root(): + """Health check endpoint""" + return { + "status": "online", + "service": "Dependency Update Automation API", + "version": "1.0.0", + } + + +@app.get("/health") +async def health_check(): + """Detailed health check including Docker and MCP server availability""" + try: + from src.integrations.mcp_server_manager import MCPServerStatus, get_mcp_status + + # Check Docker + docker_cmd = get_docker_path() + docker_check = subprocess.run( + [docker_cmd, "--version"], capture_output=True, text=True, timeout=5 + ) + docker_available = docker_check.returncode == 0 + + # Check GitHub token + github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + token_configured = github_token is not None + + # Check Anthropic API key + anthropic_key = os.getenv("ANTHROPIC_API_KEY") + api_key_configured = anthropic_key is not None + + # Check MCP server status + mcp_status = await get_mcp_status() + mcp_running = mcp_status.status == MCPServerStatus.RUNNING + + all_healthy = all( + [docker_available, token_configured, api_key_configured, mcp_running] + ) + + return { + "status": "healthy" if all_healthy else "degraded", + "checks": { + "docker": "available" if docker_available else "unavailable", + "github_token": "configured" if token_configured else "missing", + "anthropic_api_key": "configured" if api_key_configured else "missing", + "mcp_server": { + "status": mcp_status.status.value, + "tools_count": mcp_status.tools_count, + "container_id": mcp_status.container_id[:12] + if mcp_status.container_id + else None, + "error": mcp_status.error_message, + }, + }, + } + except Exception as e: + return {"status": "unhealthy", "error": str(e)} + + +@app.get("/api/mcp/status") +async def mcp_status(): + """ + Get detailed status of the persistent MCP server. + """ + from src.integrations.mcp_server_manager import get_mcp_status + + status = await get_mcp_status() + + return { + "status": status.status.value, + "container_id": status.container_id, + "tools_count": status.tools_count, + "error_message": status.error_message, + "reconnect_attempts": status.reconnect_attempts, + } + + +@app.get("/api/mcp/tools") +async def mcp_tools(): + """ + List all available MCP tools. + """ + from src.integrations.mcp_server_manager import get_mcp_server + + server = await get_mcp_server() + + if not server.is_running: + raise HTTPException(status_code=503, detail="MCP server is not running") + + return {"tools_count": len(server.available_tools), "tools": server.available_tools} + + +@app.post("/api/mcp/reconnect") +async def mcp_reconnect(): + """ + Force reconnection to the MCP server. + """ + from src.integrations.mcp_server_manager import get_mcp_server + + server = await get_mcp_server() + success = await server.reconnect() + + if success: + return { + "status": "success", + "message": "MCP server reconnected successfully", + "tools_count": len(server.available_tools), + } + else: + raise HTTPException( + status_code=503, detail=f"Failed to reconnect: {server.info.error_message}" + ) + + +@app.post("/api/repositories/update", response_model=JobResponse) +async def update_repository( + request: RepositoryRequest, background_tasks: BackgroundTasks +): + """ + Analyze and update dependencies for a repository. + + The process runs in the background and returns a job ID for status tracking. + """ + # Generate job ID + import uuid + + job_id = str(uuid.uuid4()) + + # Initialize job + jobs_storage[job_id] = { + "job_id": job_id, + "status": "queued", + "repository": request.repository, + "result": None, + "error": None, + } + + # Add to background tasks + background_tasks.add_task( + process_repository_update, + job_id=job_id, + repository=request.repository, + github_token=request.github_token, + ) + + return JobResponse( + job_id=job_id, + status="queued", + message="Repository update job has been queued", + repository=request.repository, + ) + + +@app.get("/api/jobs/{job_id}", response_model=JobStatusResponse) +async def get_job_status(job_id: str): + """ + Get the status of a repository update job. + """ + if job_id not in jobs_storage: + raise HTTPException(status_code=404, detail="Job not found") + + job = jobs_storage[job_id] + + usage_data = None + if job.get("usage"): + u = job["usage"] + usage_data = UsageResponse( + input_tokens=u.get("input_tokens", 0), + output_tokens=u.get("output_tokens", 0), + total_tokens=u.get("total_tokens", 0), + llm_calls=u.get("llm_calls", 0), + estimated_cost_usd=u.get("estimated_cost_usd", 0.0), + ) + + return JobStatusResponse( + job_id=job["job_id"], + status=job["status"], + result=job.get("result"), + error=job.get("error"), + usage=usage_data, + ) + + +@app.get("/api/jobs") +async def list_jobs(): + """List all jobs and their current status""" + return {"total": len(jobs_storage), "jobs": list(jobs_storage.values())} + + +if __name__ == "__main__": + import uvicorn + + # Get port from environment or use default + port = int(os.getenv("PORT", 8000)) + host = os.getenv("HOST", "0.0.0.0") + + print(f"Starting server on {host}:{port}") + + uvicorn.run( + "src.api.server:app", host=host, port=port, reload=True, log_level="info" + ) diff --git a/build/lib/src/api/startup.py b/build/lib/src/api/startup.py new file mode 100644 index 0000000..30fcfc7 --- /dev/null +++ b/build/lib/src/api/startup.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +Startup script for the Dependency Update Automation API. + +This script: +1. Validates all prerequisites (Docker, API keys, tokens) +2. Sets up the GitHub MCP Docker image +3. Starts the FastAPI server + +Usage: + python -m src.api.startup [--host HOST] [--port PORT] [--no-reload] +""" + +import argparse +import logging.config +import os +import subprocess +import sys +from pathlib import Path + +from dotenv import load_dotenv + +_config_dir = Path(__file__).resolve().parent.parent / "config" +logging.config.fileConfig( + str(_config_dir / "logging.conf"), disable_existing_loggers=False +) + +from src.utils.docker import get_docker_path + + +def check_python_version(): + """Verify Python version is 3.9+""" + if sys.version_info < (3, 9): + print("Error: Python 3.9 or higher is required") + print(f"Current version: {sys.version}") + return False + print(f"Python version: {sys.version.split()[0]}") + return True + + +def check_docker(): + """Verify Docker is installed and running""" + print("\nChecking Docker...") + docker_cmd = get_docker_path() + + try: + result = subprocess.run( + [docker_cmd, "--version"], capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + print(f" Docker: {result.stdout.strip()}") + + # Check if Docker daemon is running + info_result = subprocess.run( + [docker_cmd, "info"], capture_output=True, text=True, timeout=10 + ) + if info_result.returncode == 0: + print(" Docker daemon: Running") + return True + else: + print(" Error: Docker daemon is not running") + print(" Please start Docker Desktop or the Docker service") + return False + else: + print(" Error: Docker is not installed") + return False + except FileNotFoundError: + print(" Error: Docker is not installed") + print(" Please install Docker: https://docs.docker.com/get-docker/") + return False + except subprocess.TimeoutExpired: + print(" Error: Docker command timed out") + return False + + +def check_environment_variables(): + """Verify required environment variables are set""" + print("\nChecking environment variables...") + + # Load .env file + load_dotenv() + + all_set = True + + # Check ANTHROPIC_API_KEY + anthropic_key = os.getenv("ANTHROPIC_API_KEY") + if anthropic_key: + print(f" ANTHROPIC_API_KEY: Set ({anthropic_key[:8]}...)") + else: + print(" ANTHROPIC_API_KEY: NOT SET") + print(" Set it in .env file or export ANTHROPIC_API_KEY=your-key") + all_set = False + + # Check GITHUB_PERSONAL_ACCESS_TOKEN + github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + if github_token: + print(f" GITHUB_PERSONAL_ACCESS_TOKEN: Set ({github_token[:8]}...)") + else: + print(" GITHUB_PERSONAL_ACCESS_TOKEN: NOT SET") + print(" Export it: export GITHUB_PERSONAL_ACCESS_TOKEN=your-token") + all_set = False + + return all_set + + +def check_dependencies(): + """Verify Python dependencies are installed""" + print("\nChecking Python dependencies...") + + required = [ + "fastapi", + "uvicorn", + "pydantic", + "langchain", + "langchain_anthropic", + "mcp", + "dotenv", + ] + + missing = [] + for package in required: + try: + __import__(package) + print(f" {package}: OK") + except ImportError: + print(f" {package}: MISSING") + missing.append(package) + + if missing: + print(f"\nMissing packages: {', '.join(missing)}") + print("Run: pip install -r requirements.txt") + return False + + return True + + +def pull_mcp_image(): + """Pull the GitHub MCP Docker image""" + print("\nPulling GitHub MCP Docker image...") + print(" This may take a few minutes on first run...") + docker_cmd = get_docker_path() + + try: + result = subprocess.run( + [docker_cmd, "pull", "ghcr.io/github/github-mcp-server"], + capture_output=False, # Show progress + timeout=600, # 10 minutes + ) + + if result.returncode == 0: + print(" GitHub MCP image: Ready") + return True + else: + print(" Warning: Could not pull image (may use cached version)") + return True # Don't fail, might have cached image + + except subprocess.TimeoutExpired: + print(" Error: Pulling image timed out") + return False + + +def start_server(host: str, port: int, reload: bool): + """Start the FastAPI server""" + print(f"\n{'=' * 60}") + print(f"Starting Dependency Update Automation API") + print(f"{'=' * 60}") + print(f" Host: {host}") + print(f" Port: {port}") + print(f" Auto-reload: {'Enabled' if reload else 'Disabled'}") + print(f" API Docs: http://{host}:{port}/docs") + print(f" Health Check: http://{host}:{port}/health") + print(f"{'=' * 60}\n") + + import uvicorn + + reload_kwargs = {} + if reload: + reload_kwargs = { + "reload_dirs": ["src"], + "reload_excludes": ["*.pyc", "__pycache__", "*.log", ".git", "*.egg-info"], + } + + uvicorn.run( + "src.api.server:app", + host=host, + port=port, + reload=reload, + log_level="info", + **reload_kwargs, + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Start the Dependency Update Automation API Server" + ) + parser.add_argument( + "--host", + default=os.getenv("HOST", "0.0.0.0"), + help="Host to bind to (default: 0.0.0.0)", + ) + parser.add_argument( + "--port", + type=int, + default=int(os.getenv("PORT", 8000)), + help="Port to bind to (default: 8000)", + ) + parser.add_argument( + "--no-reload", action="store_true", help="Disable auto-reload (for production)" + ) + parser.add_argument( + "--skip-checks", action="store_true", help="Skip prerequisite checks" + ) + + args = parser.parse_args() + + print("=" * 60) + print("Dependency Update Automation API - Startup") + print("=" * 60) + + if not args.skip_checks: + # Run all checks + checks_passed = True + + if not check_python_version(): + checks_passed = False + + if not check_docker(): + checks_passed = False + + if not check_environment_variables(): + checks_passed = False + + if not check_dependencies(): + checks_passed = False + + if not checks_passed: + print("\n" + "=" * 60) + print("STARTUP FAILED: Please fix the issues above") + print("=" * 60) + print("\nQuick fix commands:") + print(" 1. Start Docker Desktop (or: sudo systemctl start docker)") + print(" 2. pip install -r requirements.txt") + print(" 3. cp .env.example .env && edit .env") + print(" 4. export GITHUB_PERSONAL_ACCESS_TOKEN=your-token") + sys.exit(1) + + # Pull MCP image + if not pull_mcp_image(): + print("\nWarning: Could not pull MCP image, continuing anyway...") + + # Start the server + try: + start_server(host=args.host, port=args.port, reload=not args.no_reload) + except KeyboardInterrupt: + print("\n\nServer stopped by user") + except Exception as e: + print(f"\nError starting server: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/build/lib/src/callbacks/__init__.py b/build/lib/src/callbacks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/callbacks/agent_activity.py b/build/lib/src/callbacks/agent_activity.py new file mode 100644 index 0000000..2ea7a94 --- /dev/null +++ b/build/lib/src/callbacks/agent_activity.py @@ -0,0 +1,408 @@ +""" +Real-time agent activity logging via LangChain callbacks. + +Provides verbose, human-readable console output showing what each agent +is doing: LLM thinking, tool calls, tool results, and errors. +""" + +import json +import logging +import time +from typing import Any, Optional +from uuid import UUID + +from langchain_core.agents import AgentAction, AgentFinish +from langchain_core.callbacks.base import BaseCallbackHandler +from langchain_core.messages import BaseMessage +from langchain_core.outputs import LLMResult + +logger = logging.getLogger("app.agent_activity") + +# Anthropic pricing per 1M tokens (Claude Sonnet 4.5) +# https://docs.anthropic.com/en/docs/about-claude/pricing +ANTHROPIC_PRICING = { + "claude-sonnet-4-5-20250929": { + "input_per_1m": 3.00, + "output_per_1m": 15.00, + }, + "claude-sonnet-4-20250514": { + "input_per_1m": 3.00, + "output_per_1m": 15.00, + }, + "claude-haiku-4-5-20251001": { + "input_per_1m": 0.80, + "output_per_1m": 4.00, + }, + "claude-opus-4-6": { + "input_per_1m": 15.00, + "output_per_1m": 75.00, + }, +} + +# Default fallback pricing (Sonnet 4.5) +DEFAULT_PRICING = {"input_per_1m": 3.00, "output_per_1m": 15.00} + + +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + + ORCHESTRATOR = "\033[1;35m" # Bold Magenta + ANALYZER = "\033[1;36m" # Bold Cyan + UPDATER = "\033[1;33m" # Bold Yellow + + THINKING = "\033[34m" # Blue + TOOL_CALL = "\033[32m" # Green + TOOL_RESULT = "\033[90m" # Gray + ERROR = "\033[1;31m" # Bold Red + SUCCESS = "\033[1;32m" # Bold Green + + +AGENT_STYLES = { + "orchestrator": {"prefix": "ORCHESTRATOR", "color": Colors.ORCHESTRATOR}, + "analyzer": {"prefix": "ANALYZER ", "color": Colors.ANALYZER}, + "updater": {"prefix": "UPDATER ", "color": Colors.UPDATER}, +} + + +def _truncate(text: str, max_len: int = 200) -> str: + text = str(text).replace("\n", " ").strip() + if len(text) <= max_len: + return text + return text[:max_len] + "..." + + +def _format_tool_args(input_str: str, max_len: int = 150) -> str: + try: + args = json.loads(input_str) + if isinstance(args, dict): + parts = [] + for k, v in args.items(): + v_str = str(v) + if len(v_str) > 60: + v_str = v_str[:60] + "..." + parts.append(f"{k}={v_str}") + return ", ".join(parts) + except (json.JSONDecodeError, TypeError): + pass + return _truncate(input_str, max_len) + + +def _extract_tool_result_summary(output: Any, max_len: int = 200) -> str: + output_str = str(output) + try: + data = json.loads(output_str) + if isinstance(data, dict): + summary_parts = [] + for key in [ + "status", + "message", + "repo_path", + "language", + "package_manager", + "outdated_count", + "total_updates", + "pr_url", + "issue_url", + "branch_name", + "succeeded", + "from_cache", + ]: + if key in data: + val = str(data[key]) + if len(val) > 80: + val = val[:80] + "..." + summary_parts.append(f"{key}={val}") + if summary_parts: + return ", ".join(summary_parts[:5]) + except (json.JSONDecodeError, TypeError): + pass + return _truncate(output_str, max_len) + + +class AgentActivityHandler(BaseCallbackHandler): + """ + LangChain callback handler that provides verbose, real-time logging + of agent activity to console and Python logging framework. + + Usage: + handler = AgentActivityHandler("orchestrator") + agent.invoke({"messages": [...]}, config={"callbacks": [handler]}) + """ + + def __init__(self, agent_name: str, job_id: Optional[str] = None): + self.agent_name = agent_name + self.job_id = job_id + self._style = AGENT_STYLES.get( + agent_name, {"prefix": agent_name.upper(), "color": Colors.BOLD} + ) + self._llm_start_time: Optional[float] = None + self._tool_start_times: dict[UUID, float] = {} + self.activity_log: list[dict[str, Any]] = [] + + # Token usage tracking + self.total_input_tokens: int = 0 + self.total_output_tokens: int = 0 + self.llm_call_count: int = 0 + self._model_name: Optional[str] = None + + # Sub-handler tracking (for aggregating child agent costs) + self._child_handlers: list["AgentActivityHandler"] = [] + + def _prefix(self) -> str: + color = self._style["color"] + prefix = self._style["prefix"] + return f"{color}[{prefix}]{Colors.RESET}" + + def _log_activity(self, event_type: str, detail: str, **extra: Any) -> None: + entry = { + "agent": self.agent_name, + "event": event_type, + "detail": detail, + "job_id": self.job_id, + **extra, + } + self.activity_log.append(entry) + logger.debug(json.dumps(entry)) + + # โ”€โ”€ LLM / Chat Model Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[BaseMessage]], + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + self._llm_start_time = time.time() + msg_count = sum(len(batch) for batch in messages) + + # Capture model name for pricing lookup + if self._model_name is None: + model = kwargs.get("invocation_params", {}).get("model") + if not model: + model = serialized.get("kwargs", {}).get("model") + if model: + self._model_name = model + + print( + f"{self._prefix()} {Colors.THINKING}Thinking... " + f"({msg_count} messages in context){Colors.RESET}" + ) + self._log_activity("llm_start", f"Chat model invoked with {msg_count} messages") + + def on_llm_end( + self, + response: LLMResult, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + elapsed = "" + if self._llm_start_time: + elapsed = f" ({time.time() - self._llm_start_time:.1f}s)" + self._llm_start_time = None + + token_info = "" + if response.llm_output and "usage" in response.llm_output: + usage = response.llm_output["usage"] + inp = usage.get("input_tokens", 0) + out = usage.get("output_tokens", 0) + self.total_input_tokens += inp + self.total_output_tokens += out + self.llm_call_count += 1 + token_info = f" | tokens: {inp} in, {out} out" + + # Also check per-generation usage_metadata (LangChain standard) + if not token_info and response.generations: + for gen_list in response.generations: + for gen in gen_list: + meta = getattr(gen, "message", None) + if meta and hasattr(meta, "usage_metadata") and meta.usage_metadata: + inp = meta.usage_metadata.get("input_tokens", 0) + out = meta.usage_metadata.get("output_tokens", 0) + self.total_input_tokens += inp + self.total_output_tokens += out + self.llm_call_count += 1 + token_info = f" | tokens: {inp} in, {out} out" + + print( + f"{self._prefix()} {Colors.DIM}LLM responded{elapsed}{token_info}" + f"{Colors.RESET}" + ) + self._log_activity("llm_end", f"LLM responded{elapsed}{token_info}") + + def on_llm_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + print(f"{self._prefix()} {Colors.ERROR}LLM ERROR: {error}{Colors.RESET}") + self._log_activity("llm_error", str(error)) + + # โ”€โ”€ Tool Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def on_tool_start( + self, + serialized: dict[str, Any], + input_str: str, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, + inputs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + self._tool_start_times[run_id] = time.time() + tool_name = serialized.get("name", "unknown_tool") + formatted_args = _format_tool_args(input_str) + + print( + f"{self._prefix()} {Colors.TOOL_CALL}>>> Calling tool: " + f"{Colors.BOLD}{tool_name}{Colors.RESET}" + ) + if formatted_args: + print( + f"{self._prefix()} {Colors.DIM} args: {formatted_args}{Colors.RESET}" + ) + + self._log_activity("tool_start", tool_name, args=_truncate(input_str, 500)) + + def on_tool_end( + self, + output: Any, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + elapsed = "" + if run_id in self._tool_start_times: + elapsed = f" ({time.time() - self._tool_start_times.pop(run_id):.1f}s)" + + summary = _extract_tool_result_summary(output) + + output_str = str(output) + is_error = '"status": "error"' in output_str or '"status":"error"' in output_str + + if is_error: + print( + f"{self._prefix()} {Colors.ERROR}<<< Tool returned error{elapsed}: " + f"{summary}{Colors.RESET}" + ) + else: + print( + f"{self._prefix()} {Colors.TOOL_RESULT}<<< Tool result{elapsed}: " + f"{summary}{Colors.RESET}" + ) + + self._log_activity("tool_end", summary, elapsed=elapsed) + + def on_tool_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + self._tool_start_times.pop(run_id, None) + print( + f"{self._prefix()} {Colors.ERROR}<<< Tool EXCEPTION: {error}{Colors.RESET}" + ) + self._log_activity("tool_error", str(error)) + + # โ”€โ”€ Agent Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def on_agent_action( + self, + action: AgentAction, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + print( + f"{self._prefix()} {Colors.BOLD}Agent decided: {action.tool}{Colors.RESET}" + ) + self._log_activity("agent_action", action.tool) + + def on_agent_finish( + self, + finish: AgentFinish, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + summary = _truncate(finish.return_values.get("output", str(finish.log)), 300) + print( + f"{self._prefix()} {Colors.SUCCESS}Agent finished: {summary}{Colors.RESET}" + ) + self._log_activity("agent_finish", summary) + + # โ”€โ”€ Usage / Cost Tracking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def add_child_handler(self, child: "AgentActivityHandler") -> None: + """Register a child agent's handler so its tokens are included in the total.""" + self._child_handlers.append(child) + + def get_usage_summary(self) -> dict: + """ + Return aggregated token usage and estimated cost for this agent + and all its child agents (analyzer, updater). + + Cost is calculated using Anthropic's per-model pricing. + """ + total_in = self.total_input_tokens + total_out = self.total_output_tokens + total_calls = self.llm_call_count + + children_summaries = [] + for child in self._child_handlers: + child_summary = child.get_usage_summary() + total_in += child_summary["input_tokens"] + total_out += child_summary["output_tokens"] + total_calls += child_summary["llm_calls"] + children_summaries.append(child_summary) + + pricing = ANTHROPIC_PRICING.get(self._model_name, DEFAULT_PRICING) + input_cost = (total_in / 1_000_000) * pricing["input_per_1m"] + output_cost = (total_out / 1_000_000) * pricing["output_per_1m"] + total_cost = input_cost + output_cost + + return { + "agent": self.agent_name, + "model": self._model_name or "unknown", + "input_tokens": total_in, + "output_tokens": total_out, + "total_tokens": total_in + total_out, + "llm_calls": total_calls, + "estimated_cost_usd": round(total_cost, 6), + "cost_breakdown": { + "input_cost_usd": round(input_cost, 6), + "output_cost_usd": round(output_cost, 6), + }, + "children": children_summaries if children_summaries else None, + } + + # โ”€โ”€ Chain Events (minimal, to avoid noise) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def on_chain_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: UUID | None = None, + **kwargs: Any, + ) -> None: + print(f"{self._prefix()} {Colors.ERROR}Chain error: {error}{Colors.RESET}") + self._log_activity("chain_error", str(error)) diff --git a/build/lib/src/cli/__init__.py b/build/lib/src/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/cli/diagnose.py b/build/lib/src/cli/diagnose.py new file mode 100644 index 0000000..aac99b9 --- /dev/null +++ b/build/lib/src/cli/diagnose.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +Comprehensive GitHub MCP Diagnostic Script + +This script performs thorough testing of GitHub MCP integration: +1. Checks all prerequisites (Docker, token, Python packages) +2. Tests Docker connectivity and image availability +3. Tests MCP server startup and connection +4. Tests basic MCP operations +5. Provides detailed diagnostics for troubleshooting +""" + +import asyncio +import json +import os +import subprocess +import sys +from typing import Optional, Tuple + + +class Colors: + """ANSI color codes for terminal output.""" + + GREEN = "\033[92m" + RED = "\033[91m" + YELLOW = "\033[93m" + BLUE = "\033[94m" + BOLD = "\033[1m" + END = "\033[0m" + + +def print_header(text: str): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'=' * 70}{Colors.END}") + print(f"{Colors.BOLD}{Colors.BLUE}{text.center(70)}{Colors.END}") + print(f"{Colors.BOLD}{Colors.BLUE}{'=' * 70}{Colors.END}\n") + + +def print_test(name: str): + print(f"{Colors.BOLD}Testing: {name}{Colors.END}") + + +def print_success(message: str): + print(f"{Colors.GREEN} PASS: {message}{Colors.END}") + + +def print_error(message: str): + print(f"{Colors.RED} FAIL: {message}{Colors.END}") + + +def print_warning(message: str): + print(f"{Colors.YELLOW} WARN: {message}{Colors.END}") + + +def print_info(message: str): + print(f"{Colors.BLUE} INFO: {message}{Colors.END}") + + +def run_command(cmd: list, capture_output=True, timeout=30) -> Tuple[int, str, str]: + try: + result = subprocess.run( + cmd, capture_output=capture_output, text=True, timeout=timeout + ) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return -1, "", f"Command timed out after {timeout} seconds" + except FileNotFoundError: + return -1, "", f"Command not found: {cmd[0]}" + except Exception as e: + return -1, "", str(e) + + +def check_python_version() -> bool: + print_test("Python version") + + version = sys.version_info + version_str = f"{version.major}.{version.minor}.{version.micro}" + + if version.major >= 3 and version.minor >= 8: + print_success(f"Python {version_str} (compatible)") + return True + else: + print_error(f"Python {version_str} (requires Python 3.8+)") + return False + + +def check_container_runtime() -> tuple[bool, str]: + print_test("Container runtime") + + runtimes = { + "docker": "Docker Desktop / OrbStack / Rancher Desktop", + "podman": "Podman Desktop / Podman", + "nerdctl": "containerd with nerdctl", + } + + for runtime, description in runtimes.items(): + exit_code, stdout, stderr = run_command([runtime, "--version"]) + if exit_code == 0: + version = stdout.strip().split("\n")[0] + print_success(f"{runtime} installed: {version}") + print_info(f"Runtime type: {description}") + return True, runtime + + print_error("No container runtime found") + print_info("Install one of:") + print_info(" Docker Desktop: https://www.docker.com/products/docker-desktop") + print_info(" OrbStack (macOS): https://orbstack.dev/") + print_info(" Podman Desktop: https://podman-desktop.io/") + print_info(" Rancher Desktop: https://rancherdesktop.io/") + return False, "" + + +def check_container_runtime_working(runtime: str) -> bool: + print_test(f"{runtime.capitalize()} runtime status") + + exit_code, stdout, stderr = run_command([runtime, "ps"], timeout=10) + + if exit_code == 0: + print_success(f"{runtime.capitalize()} runtime is working") + return True + else: + print_error(f"{runtime.capitalize()} runtime is not responding") + if runtime == "docker": + print_info( + "Start Docker Desktop, OrbStack, or run: sudo systemctl start docker" + ) + elif runtime == "podman": + print_info("Start Podman Desktop or run: podman machine start") + if stderr: + print(f" Error: {stderr.strip()}") + return False + + +def check_container_image(runtime: str) -> bool: + print_test("GitHub MCP container image") + + exit_code, stdout, stderr = run_command( + [ + runtime, + "images", + "ghcr.io/github/github-mcp-server", + "--format", + "{{.Repository}}:{{.Tag}}", + ] + ) + + if exit_code == 0 and stdout.strip(): + print_success(f"Image available locally: {stdout.strip()}") + return True + else: + print_warning("Image not found locally") + print_info("Attempting to pull image...") + + exit_code, stdout, stderr = run_command( + [runtime, "pull", "ghcr.io/github/github-mcp-server"], timeout=120 + ) + + if exit_code == 0: + print_success("Successfully pulled GitHub MCP image") + return True + else: + print_error("Failed to pull container image") + if stderr: + print(f" Error: {stderr.strip()}") + return False + + +def check_github_token() -> Optional[str]: + print_test("GitHub Personal Access Token") + + token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + + if token: + masked_token = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***" + print_success(f"Token found: {masked_token}") + print_info(f"Token length: {len(token)} characters") + return token + else: + print_error("GITHUB_PERSONAL_ACCESS_TOKEN not set") + print_info("Set token: export GITHUB_PERSONAL_ACCESS_TOKEN='your_token'") + print_info("Create token: https://github.com/settings/tokens") + return None + + +def check_python_packages() -> bool: + print_test("Python packages") + + required_packages = { + "mcp": "Model Context Protocol client", + "anthropic": "Anthropic API client", + "dotenv": "Environment variable loader", + } + + all_installed = True + + for package, description in required_packages.items(): + try: + if package == "dotenv": + __import__("dotenv") + else: + __import__(package) + print_success(f"{package}: installed ({description})") + except ImportError: + print_error(f"{package}: NOT installed ({description})") + all_installed = False + + if not all_installed: + print_info("Install packages: pip install -r requirements.txt") + + return all_installed + + +def test_container_run(runtime: str) -> bool: + print_test("Container execution test") + + exit_code, stdout, stderr = run_command( + [runtime, "run", "--rm", "alpine", "echo", "Container works!"], timeout=30 + ) + + if exit_code == 0 and "Container works!" in stdout: + print_success(f"{runtime.capitalize()} can run containers successfully") + return True + else: + print_error("Failed to run test container") + if stderr: + print(f" Error: {stderr.strip()}") + return False + + +async def test_mcp_connection(token: str) -> bool: + print_test("MCP client connection") + + try: + from src.integrations.github_mcp_client import GitHubMCPClient + + print_info("Initializing MCP client...") + + async with GitHubMCPClient(token) as client: + print_success("Successfully connected to GitHub MCP server") + + print_info("Fetching available tools...") + tools = await client.list_available_tools() + + print_success(f"Found {len(tools)} available tools:") + for tool in tools[:10]: + print(f" - {tool}") + if len(tools) > 10: + print(f" ... and {len(tools) - 10} more") + + return True + + except ImportError as e: + print_error(f"Failed to import github_mcp_client: {e}") + return False + except Exception as e: + print_error(f"MCP connection failed: {str(e)}") + + import traceback + + error_details = traceback.format_exc() + print("\n" + Colors.YELLOW + "Detailed error trace:" + Colors.END) + print(error_details) + + return False + + +async def test_mcp_tool_call(token: str) -> bool: + print_test("MCP tool execution") + + try: + from src.integrations.github_mcp_client import GitHubMCPClient + + print_info("Testing get_me tool (gets authenticated user info)...") + + async with GitHubMCPClient(token) as client: + result = await client.session.call_tool("get_me", arguments={}) + + if result.content and len(result.content) > 0: + print_success("Successfully called MCP tool") + + try: + response_text = ( + result.content[0].text + if hasattr(result.content[0], "text") + else str(result.content[0]) + ) + user_data = ( + json.loads(response_text) + if isinstance(response_text, str) + else response_text + ) + + if isinstance(user_data, dict): + print(f" Authenticated as: {user_data.get('login', 'N/A')}") + print(f" User type: {user_data.get('type', 'N/A')}") + except (json.JSONDecodeError, KeyError, TypeError): + pass + + return True + else: + print_error("Tool call returned no content") + return False + + except Exception as e: + print_error(f"Tool execution failed: {str(e)}") + import traceback + + print(f" {traceback.format_exc()[:200]}") + return False + + +async def run_all_tests(): + print_header("GitHub MCP Integration Diagnostic Tool") + + results = {} + + # Prerequisites + print_header("1. Prerequisites Check") + results["python_version"] = check_python_version() + + runtime_installed, detected_runtime = check_container_runtime() + results["runtime_installed"] = runtime_installed + + if runtime_installed: + results["runtime_working"] = check_container_runtime_working(detected_runtime) + else: + print_warning("Skipping runtime checks (no container runtime found)") + results["runtime_working"] = False + detected_runtime = "docker" + + results["python_packages"] = check_python_packages() + + token = check_github_token() + results["github_token"] = token is not None + + # Container tests + if results["runtime_installed"] and results["runtime_working"]: + print_header(f"2. Container Functionality Tests ({detected_runtime})") + results["container_run"] = test_container_run(detected_runtime) + results["container_image"] = check_container_image(detected_runtime) + else: + print_warning("Skipping container tests (container runtime not available)") + results["container_run"] = False + results["container_image"] = False + + # MCP tests + if all( + [ + results["python_packages"], + results["github_token"], + results["runtime_installed"], + results["runtime_working"], + results["container_image"], + ] + ): + print_header("3. MCP Integration Tests") + results["mcp_connection"] = await test_mcp_connection(token) + + if results["mcp_connection"]: + results["mcp_tool_call"] = await test_mcp_tool_call(token) + else: + print_warning("Skipping tool call test (connection failed)") + results["mcp_tool_call"] = False + else: + print_warning("Skipping MCP tests (prerequisites not met)") + results["mcp_connection"] = False + results["mcp_tool_call"] = False + + # Summary + print_header("Test Results Summary") + + passed = sum(1 for v in results.values() if v) + total = len(results) + + for test_name, passed_test in results.items(): + status = ( + f"{Colors.GREEN}PASS{Colors.END}" + if passed_test + else f"{Colors.RED}FAIL{Colors.END}" + ) + print(f"{test_name.replace('_', ' ').title()}: {status}") + + print(f"\n{Colors.BOLD}Overall: {passed}/{total} tests passed{Colors.END}") + + if passed == total: + print_header("All Tests Passed!") + print_success("GitHub MCP integration is working correctly!") + else: + print_header("Some Tests Failed") + print_error("GitHub MCP integration has issues. Review the errors above.") + + print(f"\n{Colors.BOLD}Troubleshooting Suggestions:{Colors.END}") + + if not results["runtime_installed"]: + print(" Install a container runtime:") + print( + " - Docker Desktop: https://www.docker.com/products/docker-desktop" + ) + print(" - OrbStack (macOS): https://orbstack.dev/") + print(" - Podman Desktop: https://podman-desktop.io/") + + if not results["runtime_working"]: + print(" Start your container runtime (Docker Desktop, OrbStack, etc.)") + + if not results["github_token"]: + print(" Set GITHUB_PERSONAL_ACCESS_TOKEN environment variable") + print(" Create token at: https://github.com/settings/tokens") + + if not results["python_packages"]: + print(" Install Python packages: pip install -r requirements.txt") + + if not results.get("container_image"): + print(" Manually pull image: docker pull ghcr.io/github/github-mcp-server") + + if results.get("container_image") and not results.get("mcp_connection"): + print(" Check container logs for MCP server errors") + print(" Verify GitHub token has correct permissions (repo, workflow)") + print(" Check network connectivity") + + return passed == total + + +def main(): + try: + success = asyncio.run(run_all_tests()) + sys.exit(0 if success else 1) + except KeyboardInterrupt: + print(f"\n\n{Colors.YELLOW}Test interrupted by user{Colors.END}") + sys.exit(130) + except Exception as e: + print(f"\n\n{Colors.RED}Unexpected error: {e}{Colors.END}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/build/lib/src/config/__init__.py b/build/lib/src/config/__init__.py new file mode 100644 index 0000000..171ca06 --- /dev/null +++ b/build/lib/src/config/__init__.py @@ -0,0 +1 @@ +DEFAULT_LLM_MODEL = "claude-sonnet-4-5-20250929" diff --git a/build/lib/src/config/language_map.py b/build/lib/src/config/language_map.py new file mode 100644 index 0000000..0ed26e5 --- /dev/null +++ b/build/lib/src/config/language_map.py @@ -0,0 +1,220 @@ +LANGUAGE_PACKAGE_BUILD_MAP = { + "python": { + "detect_files": [ + "requirements.txt", + "requirements.in", + "pyproject.toml", + "Pipfile", + "setup.py", + ], + "package_managers": { + "pip": { + "lock_files": [], + "install": "pip install -r requirements.txt", + "build": "pip install -r requirements.txt", + "outdated_cmd": "pip list --outdated --format json", + "output_format": "json_array", + "field_map": { + "name": "name", + "current": "version", + "latest": "latest_version", + }, + }, + "pip-tools": { + "lock_files": ["requirements.txt"], + "install": "pip install -r requirements.txt", + "build": "pip install -r requirements.txt", + "outdated_cmd": "pip list --outdated --format json", + "output_format": "json_array", + "field_map": { + "name": "name", + "current": "version", + "latest": "latest_version", + }, + }, + "pipenv": { + "lock_files": ["Pipfile.lock"], + "install": "pipenv install --deploy", + "build": "pipenv install", + "outdated_cmd": "pipenv update --outdated", + "output_format": "text", + }, + "poetry": { + "lock_files": ["poetry.lock"], + "install": "poetry install --no-root", + "build": "poetry install", + "outdated_cmd": "poetry show --outdated", + "output_format": "text", + }, + }, + }, + "nodejs": { + "detect_files": ["package.json"], + "package_managers": { + "npm": { + "lock_files": ["package-lock.json"], + "install": "npm install", + "build": "npm run build", + "outdated_cmd": "npm outdated --json", + "output_format": "json_dict", + "field_map": { + "name": "_key", + "current": "current", + "latest": "latest", + }, + }, + "yarn": { + "lock_files": ["yarn.lock"], + "install": "yarn install", + "build": "yarn build", + "outdated_cmd": "yarn outdated", + "output_format": "text", + }, + "pnpm": { + "lock_files": ["pnpm-lock.yaml"], + "install": "pnpm install", + "build": "pnpm build", + "outdated_cmd": "pnpm outdated --format json", + "output_format": "json_dict", + "field_map": { + "name": "_key", + "current": "current", + "latest": "latest", + }, + }, + }, + }, + "go": { + "detect_files": ["go.mod"], + "package_managers": { + "go-mod": { + "lock_files": ["go.sum"], + "install": "go mod download", + "build": "go build ./...", + "outdated_cmd": "go list -u -m -json all", + "output_format": "ndjson", + "field_map": { + "name": "Path", + "current": "Version", + "latest": "Update.Version", + }, + "skip_when": {"Main": True, "Update": None}, + } + }, + }, + "java": { + "detect_files": ["pom.xml", "build.gradle", "build.gradle.kts"], + "package_managers": { + "maven": { + "lock_files": [], + "install": "mvn dependency:resolve", + "build": "mvn package", + "outdated_cmd": "mvn versions:display-dependency-updates", + "output_format": "text", + }, + "gradle": { + "lock_files": ["gradle.lockfile"], + "install": "gradle build", + "build": "gradle build", + "outdated_cmd": "./gradlew dependencyUpdates", + "output_format": "text", + }, + }, + }, + "rust": { + "detect_files": ["Cargo.toml"], + "package_managers": { + "cargo": { + "lock_files": ["Cargo.lock"], + "install": "cargo fetch", + "build": "cargo build --release", + "outdated_cmd": "cargo install cargo-outdated && cargo outdated", + "output_format": "text", + } + }, + }, + "ruby": { + "detect_files": ["Gemfile"], + "package_managers": { + "bundler": { + "lock_files": ["Gemfile.lock"], + "install": "bundle install", + "build": "bundle install", + "outdated_cmd": "bundle outdated", + "output_format": "text", + } + }, + }, + "php": { + "detect_files": ["composer.json"], + "package_managers": { + "composer": { + "lock_files": ["composer.lock"], + "install": "composer install", + "build": "composer install", + "outdated_cmd": "composer outdated", + "output_format": "text", + } + }, + }, + "dotnet": { + "detect_files": ["*.csproj", "*.fsproj", "*.vbproj"], + "package_managers": { + "nuget": { + "lock_files": ["packages.lock.json"], + "install": "dotnet restore", + "build": "dotnet build", + "outdated_cmd": "dotnet list package --outdated", + "output_format": "text", + } + }, + }, + "dart": { + "detect_files": ["pubspec.yaml"], + "package_managers": { + "pub": { + "lock_files": ["pubspec.lock"], + "install": "dart pub get", + "build": "dart compile exe", + "outdated_cmd": "dart pub outdated", + "output_format": "text", + } + }, + }, + "flutter": { + "detect_files": ["pubspec.yaml"], + "package_managers": { + "flutter": { + "lock_files": ["pubspec.lock"], + "install": "flutter pub get", + "build": "flutter build", + "outdated_cmd": "flutter pub outdated", + "output_format": "text", + } + }, + }, + "swift": { + "detect_files": ["Package.swift"], + "package_managers": { + "swiftpm": { + "lock_files": ["Package.resolved"], + "install": "swift package resolve", + "build": "swift build", + "outdated_cmd": "swift package update --dry-run", + "output_format": "text", + } + }, + }, + "terraform": { + "detect_files": ["*.tf"], + "package_managers": { + "terraform": { + "lock_files": [".terraform.lock.hcl"], + "install": "terraform init", + "build": "terraform plan", + "outdated_cmd": "terraform providers lock -platform=all && terraform providers mirror ./mirror", + "output_format": "text", + } + }, + }, +} diff --git a/build/lib/src/integrations/__init__.py b/build/lib/src/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/integrations/github_mcp_client.py b/build/lib/src/integrations/github_mcp_client.py new file mode 100644 index 0000000..19bbbac --- /dev/null +++ b/build/lib/src/integrations/github_mcp_client.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +""" +GitHub MCP Client - Integration with GitHub MCP Server + +This module provides integration with the GitHub MCP (Model Context Protocol) server +to perform GitHub operations like creating PRs and issues without requiring the gh CLI. +""" + +import json +import os +import subprocess +import threading +from typing import Any, Dict, Optional + +from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +from src.utils.docker import detect_container_runtime, find_command_path + +# Thread-local storage for event loops +_thread_local = threading.local() + +load_dotenv() + + +def _get_event_loop(): + """Get or create an event loop for the current thread.""" + import asyncio + + if ( + not hasattr(_thread_local, "loop") + or _thread_local.loop is None + or _thread_local.loop.is_closed() + ): + try: + # Try to get the current event loop + loop = asyncio.get_event_loop() + if loop.is_running(): + # If there's a running loop in this thread, create a new one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + # No event loop exists in this thread, create one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + _thread_local.loop = loop + + return _thread_local.loop + + +# Re-export for backwards compatibility +_find_command_path = find_command_path +_detect_container_runtime = detect_container_runtime + + +class GitHubMCPClient: + """ + Client for interacting with GitHub via MCP server running inside a container. + + Supports multiple container runtimes: + - Docker Desktop + - OrbStack (macOS Docker alternative) + - Podman Desktop + - Rancher Desktop + - Native Podman + - containerd with nerdctl + """ + + def __init__( + self, + github_token: Optional[str] = None, + toolsets: Optional[str] = None, + container_runtime: Optional[str] = None, + ): + """ + Initialize GitHub MCP client. + + Args: + github_token: GitHub Personal Access Token (falls back to env var) + toolsets: Comma-separated list of toolsets to enable (e.g., "repos,issues,pull_requests") + Use "all" to enable all toolsets. Defaults to basic toolsets. + container_runtime: Container runtime to use (docker, podman, nerdctl) + If not specified, auto-detects available runtime. + Works with Docker Desktop, OrbStack, Podman, etc. + """ + self.github_token = github_token or os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + if not self.github_token: + raise ValueError( + "GitHub token not provided. Set GITHUB_PERSONAL_ACCESS_TOKEN " + "environment variable or pass token to constructor." + ) + + # Auto-detect or use specified container runtime + # Works with Docker Desktop, OrbStack, Podman, Rancher Desktop, etc. + if container_runtime: + self.container_runtime = container_runtime + else: + self.container_runtime = detect_container_runtime() + + # Build container arguments + # Based on: https://github.com/github/github-mcp-server + container_args = [ + "run", + "-i", + "--rm", + "-e", + f"GITHUB_PERSONAL_ACCESS_TOKEN={self.github_token}", + ] + + # Add optional toolsets configuration + if toolsets: + container_args.extend(["-e", f"GITHUB_TOOLSETS={toolsets}"]) + + container_args.extend( + [ + "ghcr.io/github/github-mcp-server", + "stdio", # Run in stdio mode for MCP communication + ] + ) + + self.server_params = StdioServerParameters( + command=self.container_runtime, + args=container_args, + env={"GITHUB_PERSONAL_ACCESS_TOKEN": self.github_token}, + ) + + self.session: Optional[ClientSession] = None + self.stdio_context = None + self.stdio = None + self.write = None + + async def __aenter__(self): + """Async context manager entry.""" + try: + self.stdio_context = stdio_client(self.server_params) + transport = await self.stdio_context.__aenter__() + self.stdio, self.write = transport + + self.session = ClientSession(self.stdio, self.write) + await self.session.__aenter__() + await self.session.initialize() + + return self + + except Exception as e: + await self._cleanup() + raise RuntimeError(f"Failed to initialize GitHub MCP client: {str(e)}") + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self._cleanup() + return False + + async def _cleanup(self): + """Clean up resources.""" + if self.session: + try: + await self.session.__aexit__(None, None, None) + except Exception: + pass + self.session = None + + if self.stdio_context: + try: + await self.stdio_context.__aexit__(None, None, None) + except Exception: + pass + self.stdio_context = None + + self.stdio = None + self.write = None + + async def list_available_tools(self) -> list: + """ + List all available tools from the GitHub MCP server. + + Returns: + List of available tool names + """ + if not self.session: + raise RuntimeError("Session not initialized. Use async context manager.") + + tools_result = await self.session.list_tools() + return [tool.name for tool in tools_result.tools] + + async def create_pull_request( + self, + repo_owner: str, + repo_name: str, + title: str, + body: str, + head: str, + base: str = "main", + ) -> Dict[str, Any]: + """ + Create a GitHub Pull Request using MCP. + + Args: + repo_owner: Repository owner (username or organization) + repo_name: Repository name + title: PR title + body: PR description + head: Source branch + base: Target branch (default: main) + + Returns: + Dictionary with PR details or error + """ + if not self.session: + raise RuntimeError("Session not initialized. Use async context manager.") + + try: + result = await self.session.call_tool( + "create_pull_request", + arguments={ + "owner": repo_owner, + "repo": repo_name, + "title": title, + "body": body, + "head": head, + "base": base, + }, + ) + + if result.content and len(result.content) > 0: + response_text = ( + result.content[0].text + if hasattr(result.content[0], "text") + else str(result.content[0]) + ) + + try: + pr_data = json.loads(response_text) + except json.JSONDecodeError: + return { + "status": "success", + "message": response_text, + "raw_response": response_text, + } + + return { + "status": "success", + "pr_url": pr_data.get("html_url", ""), + "pr_number": pr_data.get("number", ""), + "message": f"Successfully created PR #{pr_data.get('number', '')}", + "data": pr_data, + } + + return {"status": "error", "message": "No response from MCP server"} + + except Exception as e: + return { + "status": "error", + "message": f"Error creating PR via MCP: {str(e)}", + } + + async def create_issue( + self, + repo_owner: str, + repo_name: str, + title: str, + body: str, + labels: Optional[list] = None, + ) -> Dict[str, Any]: + """ + Create a GitHub Issue using MCP. + + Args: + repo_owner: Repository owner (username or organization) + repo_name: Repository name + title: Issue title + body: Issue description + labels: List of label names (default: ["dependencies"]) + + Returns: + Dictionary with Issue details or error + """ + if not self.session: + raise RuntimeError("Session not initialized. Use async context manager.") + + if labels is None: + labels = ["dependencies"] + + try: + # Use issue_write tool (actual tool name in GitHub MCP) + result = await self.session.call_tool( + "issue_write", + arguments={ + "owner": repo_owner, + "repo": repo_name, + "title": title, + "body": body, + "labels": labels, + }, + ) + + if result.content and len(result.content) > 0: + response_text = ( + result.content[0].text + if hasattr(result.content[0], "text") + else str(result.content[0]) + ) + + try: + issue_data = json.loads(response_text) + except json.JSONDecodeError: + return { + "status": "success", + "message": response_text, + "raw_response": response_text, + } + + return { + "status": "success", + "issue_url": issue_data.get("html_url", ""), + "issue_number": issue_data.get("number", ""), + "message": f"Successfully created issue #{issue_data.get('number', '')}", + "data": issue_data, + } + + return {"status": "error", "message": "No response from MCP server"} + + except Exception as e: + return { + "status": "error", + "message": f"Error creating issue via MCP: {str(e)}", + } + + async def get_repository_info( + self, repo_owner: str, repo_name: str + ) -> Dict[str, Any]: + """ + Get repository information using MCP. + + Args: + repo_owner: Repository owner + repo_name: Repository name + + Returns: + Dictionary with repository details or error + """ + if not self.session: + raise RuntimeError("Session not initialized. Use async context manager.") + + try: + # Use search_repositories to find the repo + result = await self.session.call_tool( + "search_repositories", + arguments={"query": f"repo:{repo_owner}/{repo_name}"}, + ) + + if result.content and len(result.content) > 0: + response = result.content[0].text + repo_data = ( + json.loads(response) if isinstance(response, str) else response + ) + + return {"status": "success", "data": repo_data} + else: + return {"status": "error", "message": "No response from MCP server"} + + except Exception as e: + return { + "status": "error", + "message": f"Error getting repository info: {str(e)}", + } + + +# Synchronous wrapper functions for compatibility with existing code +def create_pr_sync( + repo_name: str, + branch_name: str, + title: str, + body: str, + base_branch: str = "main", + github_token: Optional[str] = None, +) -> Dict: + """ + Synchronous wrapper for creating a PR via GitHub MCP. + + Args: + repo_name: Repository in owner/repo format + branch_name: Source branch for the PR + title: PR title + body: PR description + base_branch: Target branch (default: main) + github_token: GitHub token (optional, uses env var if not provided) + + Returns: + Dictionary with PR URL or error + """ + import asyncio + + # Parse repo_name + parts = repo_name.split("/") + if len(parts) != 2: + return { + "status": "error", + "message": f"Invalid repo_name format. Expected 'owner/repo', got '{repo_name}'", + } + + owner, repo = parts + + async def _create_pr(): + async with GitHubMCPClient(github_token) as client: + return await client.create_pull_request( + repo_owner=owner, + repo_name=repo, + title=title, + body=body, + head=branch_name, + base=base_branch, + ) + + try: + # Get or create event loop for this thread + loop = _get_event_loop() + return loop.run_until_complete(_create_pr()) + except Exception as e: + return {"status": "error", "message": f"Error in MCP client: {str(e)}"} + + +def create_issue_sync( + repo_name: str, + title: str, + body: str, + labels: Optional[str] = "dependencies", + github_token: Optional[str] = None, +) -> Dict: + """ + Synchronous wrapper for creating an issue via GitHub MCP. + + Args: + repo_name: Repository in owner/repo format + title: Issue title + body: Issue description + labels: Comma-separated labels or single label (default: dependencies) + github_token: GitHub token (optional, uses env var if not provided) + + Returns: + Dictionary with Issue URL or error + """ + import asyncio + + # Parse repo_name + parts = repo_name.split("/") + if len(parts) != 2: + return { + "status": "error", + "message": f"Invalid repo_name format. Expected 'owner/repo', got '{repo_name}'", + } + + owner, repo = parts + + # Parse labels + label_list = labels.split(",") if labels else ["dependencies"] + label_list = [label.strip() for label in label_list] + + async def _create_issue(): + async with GitHubMCPClient(github_token) as client: + return await client.create_issue( + repo_owner=owner, + repo_name=repo, + title=title, + body=body, + labels=label_list, + ) + + try: + # Get or create event loop for this thread + loop = _get_event_loop() + return loop.run_until_complete(_create_issue()) + except Exception as e: + return {"status": "error", "message": f"Error in MCP client: {str(e)}"} + + +# CLI testing +if __name__ == "__main__": + import asyncio + + async def test_connection(): + """Test GitHub MCP connection and list available tools.""" + try: + async with GitHubMCPClient() as client: + print("Successfully connected to GitHub MCP server") + print("\nAvailable tools:") + tools = await client.list_available_tools() + for tool in tools: + print(f" - {tool}") + except Exception as e: + print(f"Error connecting to GitHub MCP: {e}") + + print("Testing GitHub MCP connection...") + asyncio.run(test_connection()) diff --git a/build/lib/src/integrations/mcp_server_manager.py b/build/lib/src/integrations/mcp_server_manager.py new file mode 100644 index 0000000..b82b6cd --- /dev/null +++ b/build/lib/src/integrations/mcp_server_manager.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +""" +Persistent MCP Server Manager + +Manages a long-running GitHub MCP server container that stays active +for the lifetime of the API server, providing better performance by +avoiding container startup overhead for each request. +""" + +import asyncio +import os +import subprocess +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, List, Optional + +from dotenv import load_dotenv + +from src.utils.docker import detect_container_runtime, get_docker_path + +load_dotenv() + + +class MCPServerStatus(Enum): + """Status of the MCP server.""" + + STOPPED = "stopped" + STARTING = "starting" + RUNNING = "running" + ERROR = "error" + RECONNECTING = "reconnecting" + + +@dataclass +class MCPServerInfo: + """Information about the MCP server state.""" + + status: MCPServerStatus + container_id: Optional[str] = None + tools_count: int = 0 + error_message: Optional[str] = None + reconnect_attempts: int = 0 + + +class PersistentMCPServer: + """ + Manages a persistent GitHub MCP server connection. + + This class maintains a long-running MCP client session that can be + reused across multiple API requests, improving performance by avoiding + the overhead of starting a new container for each request. + """ + + _instance: Optional["PersistentMCPServer"] = None + _lock = asyncio.Lock() + + def __init__(self): + """Initialize the MCP server manager.""" + self.github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + self.docker_path = get_docker_path() + self.container_runtime = self._detect_runtime() + + self._client = None + self._session = None + self._stdio_context = None + self._status = MCPServerStatus.STOPPED + self._container_id: Optional[str] = None + self._tools: List[str] = [] + self._error_message: Optional[str] = None + self._reconnect_attempts = 0 + self._max_reconnect_attempts = 3 + + @classmethod + async def get_instance(cls) -> "PersistentMCPServer": + """Get or create the singleton instance.""" + if cls._instance is None: + async with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + async def shutdown_instance(cls): + """Shutdown and cleanup the singleton instance.""" + if cls._instance is not None: + await cls._instance.stop() + cls._instance = None + + def _detect_runtime(self) -> str: + """Detect available container runtime.""" + try: + return detect_container_runtime() + except RuntimeError: + return self.docker_path + + @property + def status(self) -> MCPServerStatus: + """Get current server status.""" + return self._status + + @property + def info(self) -> MCPServerInfo: + """Get detailed server information.""" + return MCPServerInfo( + status=self._status, + container_id=self._container_id, + tools_count=len(self._tools), + error_message=self._error_message, + reconnect_attempts=self._reconnect_attempts, + ) + + @property + def available_tools(self) -> List[str]: + """Get list of available MCP tools.""" + return self._tools.copy() + + @property + def is_running(self) -> bool: + """Check if the MCP server is running and healthy.""" + return self._status == MCPServerStatus.RUNNING and self._session is not None + + async def start(self) -> bool: + """ + Start the persistent MCP server. + + Returns: + True if started successfully, False otherwise + """ + if not self.github_token: + self._status = MCPServerStatus.ERROR + self._error_message = "GITHUB_PERSONAL_ACCESS_TOKEN not set" + return False + + self._status = MCPServerStatus.STARTING + self._error_message = None + + try: + from mcp import ClientSession + from mcp.client.stdio import StdioServerParameters, stdio_client + + print("Starting persistent MCP server...") + + server_params = StdioServerParameters( + command=self.container_runtime, + args=[ + "run", + "-i", + "--rm", + "-e", + f"GITHUB_PERSONAL_ACCESS_TOKEN={self.github_token}", + "ghcr.io/github/github-mcp-server", + "stdio", + ], + env={"GITHUB_PERSONAL_ACCESS_TOKEN": self.github_token}, + ) + + self._stdio_context = stdio_client(server_params) + streams = await self._stdio_context.__aenter__() + + self._session = ClientSession(*streams) + await self._session.__aenter__() + await self._session.initialize() + + # Get available tools + tools_result = await self._session.list_tools() + self._tools = [tool.name for tool in tools_result.tools] + + # Try to get container ID (for monitoring) + self._container_id = await self._get_running_container_id() + + self._status = MCPServerStatus.RUNNING + self._reconnect_attempts = 0 + + print(f"Persistent MCP server started - {len(self._tools)} tools available") + if self._container_id: + print(f" Container ID: {self._container_id[:12]}") + + return True + + except Exception as e: + self._status = MCPServerStatus.ERROR + self._error_message = str(e) + print(f"Failed to start MCP server: {e}") + await self._cleanup() + return False + + async def stop(self): + """Stop the persistent MCP server.""" + print("Stopping persistent MCP server...") + await self._cleanup() + self._status = MCPServerStatus.STOPPED + self._container_id = None + self._tools = [] + print("MCP server stopped") + + async def _cleanup(self): + """Clean up resources.""" + if self._session: + try: + await self._session.__aexit__(None, None, None) + except Exception: + pass + self._session = None + + if self._stdio_context: + try: + await self._stdio_context.__aexit__(None, None, None) + except Exception: + pass + self._stdio_context = None + + self._client = None + + async def _get_running_container_id(self) -> Optional[str]: + """Try to get the ID of the running MCP container.""" + try: + result = subprocess.run( + [ + self.docker_path, + "ps", + "-q", + "--filter", + "ancestor=ghcr.io/github/github-mcp-server", + ], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + # Return the most recent container + return result.stdout.strip().split("\n")[0] + except Exception: + pass + return None + + async def reconnect(self) -> bool: + """ + Attempt to reconnect to the MCP server. + + Returns: + True if reconnected successfully, False otherwise + """ + if self._reconnect_attempts >= self._max_reconnect_attempts: + self._status = MCPServerStatus.ERROR + self._error_message = ( + f"Max reconnect attempts ({self._max_reconnect_attempts}) exceeded" + ) + return False + + self._status = MCPServerStatus.RECONNECTING + self._reconnect_attempts += 1 + + print(f"Reconnecting to MCP server (attempt {self._reconnect_attempts})...") + + await self._cleanup() + + # Wait a bit before reconnecting + await asyncio.sleep(1) + + return await self.start() + + async def ensure_connected(self) -> bool: + """ + Ensure the MCP server is connected, attempting reconnection if needed. + + Returns: + True if connected, False otherwise + """ + if self.is_running: + return True + + if self._status == MCPServerStatus.STOPPED: + return await self.start() + + if self._status in (MCPServerStatus.ERROR, MCPServerStatus.RECONNECTING): + return await self.reconnect() + + return False + + async def call_tool( + self, tool_name: str, arguments: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Call an MCP tool. + + Args: + tool_name: Name of the tool to call + arguments: Arguments to pass to the tool + + Returns: + Tool result as a dictionary + """ + if not await self.ensure_connected(): + return { + "status": "error", + "message": f"MCP server not available: {self._error_message}", + } + + try: + result = await self._session.call_tool(tool_name, arguments=arguments) + + if result.content and len(result.content) > 0: + response_text = ( + result.content[0].text + if hasattr(result.content[0], "text") + else str(result.content[0]) + ) + + import json + + try: + return {"status": "success", "data": json.loads(response_text)} + except json.JSONDecodeError: + return {"status": "success", "data": response_text} + + return {"status": "error", "message": "No response from MCP server"} + + except Exception as e: + # Try to reconnect on error + self._status = MCPServerStatus.ERROR + self._error_message = str(e) + + if await self.reconnect(): + # Retry the call after reconnection + return await self.call_tool(tool_name, arguments) + + return {"status": "error", "message": f"MCP call failed: {str(e)}"} + + async def create_pull_request( + self, + repo_owner: str, + repo_name: str, + title: str, + body: str, + head: str, + base: str = "main", + ) -> Dict[str, Any]: + """Create a GitHub Pull Request using MCP.""" + return await self.call_tool( + "create_pull_request", + { + "owner": repo_owner, + "repo": repo_name, + "title": title, + "body": body, + "head": head, + "base": base, + }, + ) + + async def create_issue( + self, + repo_owner: str, + repo_name: str, + title: str, + body: str, + labels: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Create a GitHub Issue using MCP.""" + if labels is None: + labels = ["dependencies"] + + return await self.call_tool( + "issue_write", + { + "method": "create", + "owner": repo_owner, + "repo": repo_name, + "title": title, + "body": body, + "labels": labels, + }, + ) + + async def create_branch( + self, + repo_owner: str, + repo_name: str, + branch: str, + from_branch: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a new branch in a GitHub repository using MCP.""" + args = { + "owner": repo_owner, + "repo": repo_name, + "branch": branch, + } + if from_branch: + args["from_branch"] = from_branch + return await self.call_tool("create_branch", args) + + async def push_files( + self, + repo_owner: str, + repo_name: str, + branch: str, + files: List[Dict[str, str]], + message: str, + ) -> Dict[str, Any]: + """Push multiple files to a GitHub repository in a single commit using MCP.""" + return await self.call_tool( + "push_files", + { + "owner": repo_owner, + "repo": repo_name, + "branch": branch, + "files": files, + "message": message, + }, + ) + + +# Convenience functions for getting the global MCP server instance +async def get_mcp_server() -> PersistentMCPServer: + """Get the global persistent MCP server instance.""" + return await PersistentMCPServer.get_instance() + + +async def start_mcp_server() -> bool: + """Start the global persistent MCP server.""" + server = await get_mcp_server() + return await server.start() + + +async def stop_mcp_server(): + """Stop the global persistent MCP server.""" + await PersistentMCPServer.shutdown_instance() + + +async def get_mcp_status() -> MCPServerInfo: + """Get the status of the global MCP server.""" + server = await get_mcp_server() + return server.info diff --git a/build/lib/src/services/__init__.py b/build/lib/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/services/cache.py b/build/lib/src/services/cache.py new file mode 100644 index 0000000..6dd43c5 --- /dev/null +++ b/build/lib/src/services/cache.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +Repository Cache Module + +Provides caching functionality for repository data, analysis results, +and outdated package checks to improve performance and reduce API calls. + +Cache expiry time is configurable via CACHE_EXPIRY_HOURS environment variable. +""" + +import os +import json +import shutil +import time +from pathlib import Path +from typing import Optional, Dict, Any +from datetime import datetime, timedelta + +from dotenv import load_dotenv + +load_dotenv() + + +class RepositoryCache: + """ + Manages caching of repository data with TTL-based expiration. + + Caches: + - Repository clones (on disk) + - Dependency analysis results + - Outdated package information + """ + + def __init__(self, cache_dir: Optional[str] = None, expiry_hours: Optional[int] = None): + """ + Initialize repository cache. + + Args: + cache_dir: Directory to store cache (default: .cache/repos) + expiry_hours: Cache expiry in hours (default: from env or 24 hours) + """ + # Cache directory + if cache_dir: + self.cache_dir = Path(cache_dir) + else: + self.cache_dir = Path.home() / '.cache' / 'ai-dependency-updater' + + # Cache expiry time from environment variable or default + if expiry_hours is not None: + self.expiry_hours = expiry_hours + else: + self.expiry_hours = int(os.getenv('CACHE_EXPIRY_HOURS', '24')) + + # Create cache directories + self.repos_dir = self.cache_dir / 'repos' + self.metadata_dir = self.cache_dir / 'metadata' + + self.repos_dir.mkdir(parents=True, exist_ok=True) + self.metadata_dir.mkdir(parents=True, exist_ok=True) + + def _get_repo_cache_key(self, repo_url: str) -> str: + """ + Generate a cache key from repository URL. + + Args: + repo_url: Repository URL or owner/repo format + + Returns: + Sanitized cache key + """ + # Extract owner/repo from URL + if '/' in repo_url: + if 'github.com' in repo_url: + # https://github.com/owner/repo -> owner_repo + parts = repo_url.rstrip('/').split('/') + cache_key = f"{parts[-2]}_{parts[-1]}" + else: + # owner/repo -> owner_repo + cache_key = repo_url.replace('/', '_') + else: + cache_key = repo_url + + # Remove .git suffix if present + cache_key = cache_key.replace('.git', '') + + return cache_key + + def _get_metadata_path(self, cache_key: str) -> Path: + """Get path to metadata file for a cache key.""" + return self.metadata_dir / f"{cache_key}.json" + + def _get_repo_path(self, cache_key: str) -> Path: + """Get path to cached repository.""" + return self.repos_dir / cache_key + + def _is_cache_valid(self, metadata_path: Path) -> bool: + """ + Check if cache is still valid based on TTL. + + Args: + metadata_path: Path to metadata file + + Returns: + True if cache is valid, False if expired + """ + if not metadata_path.exists(): + return False + + try: + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + cached_time = datetime.fromisoformat(metadata.get('cached_at')) + expiry_time = cached_time + timedelta(hours=self.expiry_hours) + + return datetime.now() < expiry_time + + except (json.JSONDecodeError, ValueError, KeyError): + return False + + def get_cached_repository(self, repo_url: str) -> Optional[str]: + """ + Get cached repository path if valid. + + Args: + repo_url: Repository URL + + Returns: + Path to cached repository or None if not cached/expired + """ + cache_key = self._get_repo_cache_key(repo_url) + metadata_path = self._get_metadata_path(cache_key) + repo_path = self._get_repo_path(cache_key) + + if self._is_cache_valid(metadata_path) and repo_path.exists(): + return str(repo_path) + + return None + + def cache_repository(self, repo_url: str, repo_path: str) -> None: + """ + Cache a repository clone. + + Args: + repo_url: Repository URL + repo_path: Path to cloned repository + """ + cache_key = self._get_repo_cache_key(repo_url) + cached_repo_path = self._get_repo_path(cache_key) + metadata_path = self._get_metadata_path(cache_key) + + # Copy repository to cache + if cached_repo_path.exists(): + shutil.rmtree(cached_repo_path) + + shutil.copytree(repo_path, cached_repo_path, symlinks=True) + + # Save metadata + metadata = { + 'repo_url': repo_url, + 'cached_at': datetime.now().isoformat(), + 'expiry_hours': self.expiry_hours + } + + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + def get_cached_analysis(self, repo_url: str) -> Optional[Dict[str, Any]]: + """ + Get cached analysis results. + + Args: + repo_url: Repository URL + + Returns: + Cached analysis data or None + """ + cache_key = self._get_repo_cache_key(repo_url) + metadata_path = self._get_metadata_path(cache_key) + + if not self._is_cache_valid(metadata_path): + return None + + try: + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + return metadata.get('analysis') + + except (json.JSONDecodeError, KeyError): + return None + + def cache_analysis(self, repo_url: str, analysis_data: Dict[str, Any]) -> None: + """ + Cache analysis results. + + Args: + repo_url: Repository URL + analysis_data: Analysis results to cache + """ + cache_key = self._get_repo_cache_key(repo_url) + metadata_path = self._get_metadata_path(cache_key) + + # Load or create metadata + if metadata_path.exists(): + with open(metadata_path, 'r') as f: + metadata = json.load(f) + else: + metadata = { + 'repo_url': repo_url, + 'cached_at': datetime.now().isoformat(), + 'expiry_hours': self.expiry_hours + } + + # Add analysis data + metadata['analysis'] = analysis_data + metadata['analysis_cached_at'] = datetime.now().isoformat() + + # Save metadata + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + def get_cached_outdated(self, repo_url: str) -> Optional[Dict[str, Any]]: + """ + Get cached outdated packages information. + + Args: + repo_url: Repository URL + + Returns: + Cached outdated packages data or None + """ + cache_key = self._get_repo_cache_key(repo_url) + metadata_path = self._get_metadata_path(cache_key) + + if not self._is_cache_valid(metadata_path): + return None + + try: + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + return metadata.get('outdated_packages') + + except (json.JSONDecodeError, KeyError): + return None + + def cache_outdated(self, repo_url: str, outdated_data: Dict[str, Any]) -> None: + """ + Cache outdated packages information. + + Args: + repo_url: Repository URL + outdated_data: Outdated packages data to cache + """ + cache_key = self._get_repo_cache_key(repo_url) + metadata_path = self._get_metadata_path(cache_key) + + # Load or create metadata + if metadata_path.exists(): + with open(metadata_path, 'r') as f: + metadata = json.load(f) + else: + metadata = { + 'repo_url': repo_url, + 'cached_at': datetime.now().isoformat(), + 'expiry_hours': self.expiry_hours + } + + # Add outdated packages data + metadata['outdated_packages'] = outdated_data + metadata['outdated_cached_at'] = datetime.now().isoformat() + + # Save metadata + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + def invalidate_cache(self, repo_url: str) -> None: + """ + Invalidate cache for a specific repository. + + Args: + repo_url: Repository URL + """ + cache_key = self._get_repo_cache_key(repo_url) + metadata_path = self._get_metadata_path(cache_key) + repo_path = self._get_repo_path(cache_key) + + # Remove metadata + if metadata_path.exists(): + metadata_path.unlink() + + # Remove cached repository + if repo_path.exists(): + shutil.rmtree(repo_path) + + def cleanup_expired(self) -> int: + """ + Clean up all expired cache entries. + + Returns: + Number of cache entries removed + """ + removed = 0 + + for metadata_path in self.metadata_dir.glob('*.json'): + if not self._is_cache_valid(metadata_path): + cache_key = metadata_path.stem + repo_path = self._get_repo_path(cache_key) + + # Remove metadata + metadata_path.unlink() + + # Remove cached repository + if repo_path.exists(): + shutil.rmtree(repo_path) + + removed += 1 + + return removed + + def clear_all(self) -> None: + """Clear all cache entries.""" + if self.cache_dir.exists(): + shutil.rmtree(self.cache_dir) + + # Recreate directories + self.repos_dir.mkdir(parents=True, exist_ok=True) + self.metadata_dir.mkdir(parents=True, exist_ok=True) + + def get_cache_stats(self) -> Dict[str, Any]: + """ + Get cache statistics. + + Returns: + Dictionary with cache statistics + """ + total_entries = 0 + valid_entries = 0 + expired_entries = 0 + total_size = 0 + + for metadata_path in self.metadata_dir.glob('*.json'): + total_entries += 1 + + if self._is_cache_valid(metadata_path): + valid_entries += 1 + else: + expired_entries += 1 + + # Calculate total cache size + for item in self.cache_dir.rglob('*'): + if item.is_file(): + total_size += item.stat().st_size + + return { + 'total_entries': total_entries, + 'valid_entries': valid_entries, + 'expired_entries': expired_entries, + 'total_size_mb': round(total_size / (1024 * 1024), 2), + 'cache_dir': str(self.cache_dir), + 'expiry_hours': self.expiry_hours + } + + +# Global cache instance +_cache_instance = None + + +def get_cache() -> RepositoryCache: + """Get or create the global cache instance.""" + global _cache_instance + + if _cache_instance is None: + _cache_instance = RepositoryCache() + + return _cache_instance + + +# CLI for cache management +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description='Manage repository cache') + parser.add_argument('action', choices=['stats', 'cleanup', 'clear'], + help='Action to perform') + + args = parser.parse_args() + cache = get_cache() + + if args.action == 'stats': + stats = cache.get_cache_stats() + print("\n๐Ÿ“Š Cache Statistics:") + print(f" Total entries: {stats['total_entries']}") + print(f" Valid entries: {stats['valid_entries']}") + print(f" Expired entries: {stats['expired_entries']}") + print(f" Total size: {stats['total_size_mb']} MB") + print(f" Cache directory: {stats['cache_dir']}") + print(f" Expiry time: {stats['expiry_hours']} hours") + + elif args.action == 'cleanup': + removed = cache.cleanup_expired() + print(f"\n๐Ÿงน Cleaned up {removed} expired cache entries") + + elif args.action == 'clear': + cache.clear_all() + print("\n๐Ÿ—‘๏ธ Cleared all cache entries") diff --git a/build/lib/src/tools/__init__.py b/build/lib/src/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/tools/dependency_ops.py b/build/lib/src/tools/dependency_ops.py new file mode 100644 index 0000000..982be67 --- /dev/null +++ b/build/lib/src/tools/dependency_ops.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +""" +Dependency Operations - Helper tools for updating and rolling back dependencies +""" + +import json +import re +from dotenv import load_dotenv +from langchain_core.tools import tool + +load_dotenv() + + +@tool +def apply_all_updates( + current_content: str, outdated_packages: str, file_type: str +) -> str: + """ + Apply all dependency updates (including major versions) to a dependency file. + + Args: + current_content: Current file content + outdated_packages: JSON string with list of outdated packages + file_type: Type of file (package.json, requirements.txt, Cargo.toml, etc.) + + Returns: + JSON with updated content and list of applied updates + """ + try: + updates = json.loads(outdated_packages) + applied_updates = [] + + # Normalize: accept both "latest" and "latest_version" keys + for u in updates: + if "latest" not in u and "latest_version" in u: + u["latest"] = u["latest_version"] + + if file_type == "package.json": + package_data = json.loads(current_content) + + # Update dependencies + for section in ["dependencies", "devDependencies", "peerDependencies"]: + if section in package_data: + for update in updates: + pkg_name = update["name"] + new_version = update.get( + "latest", update.get("latest_version", "") + ) + + if pkg_name in package_data[section]: + old_version = package_data[section][pkg_name] + # Preserve version prefix (^, ~, etc.) + prefix = "" + if old_version.startswith("^"): + prefix = "^" + elif old_version.startswith("~"): + prefix = "~" + elif old_version.startswith(">="): + prefix = ">=" + + package_data[section][pkg_name] = f"{prefix}{new_version}" + applied_updates.append( + { + "name": pkg_name, + "old": update.get("current", old_version), + "new": new_version, + "section": section, + } + ) + + updated_content = json.dumps(package_data, indent=2) + + elif file_type == "requirements.txt": + lines = current_content.split("\n") + updated_lines = [] + updates_dict = {u["name"].lower(): u for u in updates} + + for line in lines: + stripped = line.strip() + + # Keep comments and empty lines + if not stripped or stripped.startswith("#"): + updated_lines.append(line) + continue + + # Parse package name + pkg_name = None + if "==" in stripped: + pkg_name = stripped.split("==")[0].strip() + elif ">=" in stripped: + pkg_name = stripped.split(">=")[0].strip() + elif "<=" in stripped: + pkg_name = stripped.split("<=")[0].strip() + else: + pkg_name = stripped + + # Update if found + if pkg_name and pkg_name.lower() in updates_dict: + update_info = updates_dict[pkg_name.lower()] + new_version = update_info.get( + "latest", update_info.get("latest_version", "") + ) + updated_lines.append(f"{pkg_name}=={new_version}") + applied_updates.append( + { + "name": pkg_name, + "old": update_info.get("current", "unknown"), + "new": new_version, + } + ) + else: + updated_lines.append(line) + + updated_content = "\n".join(updated_lines) + + elif file_type == "Cargo.toml": + lines = current_content.split("\n") + updated_lines = [] + updates_dict = {u["name"].lower(): u for u in updates} + in_dependencies = False + + for line in lines: + # Track if we're in dependencies section + if line.strip().startswith("["): + in_dependencies = "dependencies" in line.lower() + + # Try to update version + if in_dependencies and "=" in line and not line.strip().startswith("#"): + match = re.match( + r'(\s*)([a-zA-Z0-9_-]+)\s*=\s*["\']([^"\']+)["\']', line + ) + if match: + indent, pkg_name, current_version = match.groups() + if pkg_name.lower() in updates_dict: + update_info = updates_dict[pkg_name.lower()] + new_version = update_info.get( + "latest", update_info.get("latest_version", "") + ) + updated_lines.append( + f'{indent}{pkg_name} = "{new_version}"' + ) + applied_updates.append( + { + "name": pkg_name, + "old": current_version, + "new": new_version, + } + ) + continue + + updated_lines.append(line) + + updated_content = "\n".join(updated_lines) + + else: + return json.dumps( + {"status": "error", "message": f"Unsupported file type: {file_type}"} + ) + + return json.dumps( + { + "status": "success", + "updated_content": updated_content, + "applied_updates": applied_updates, + "total_updates": len(applied_updates), + } + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error applying updates: {str(e)}"} + ) + + +@tool +def rollback_major_update( + current_content: str, package_name: str, file_type: str, target_version: str +) -> str: + """ + Rollback a specific package's major version update. + + Args: + current_content: Current file content + package_name: Name of the package to rollback + file_type: Type of file (package.json, requirements.txt, etc.) + target_version: Version to rollback to + + Returns: + JSON with updated content after rollback + """ + try: + if file_type == "package.json": + package_data = json.loads(current_content) + + # Find and rollback in all sections + for section in ["dependencies", "devDependencies", "peerDependencies"]: + if section in package_data and package_name in package_data[section]: + old_value = package_data[section][package_name] + prefix = "" + if old_value.startswith("^"): + prefix = "^" + elif old_value.startswith("~"): + prefix = "~" + + package_data[section][package_name] = f"{prefix}{target_version}" + + updated_content = json.dumps(package_data, indent=2) + + elif file_type == "requirements.txt": + lines = current_content.split("\n") + updated_lines = [] + + for line in lines: + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + updated_lines.append(line) + continue + + # Check if this is the package to rollback + if "==" in stripped: + pkg_name = stripped.split("==")[0].strip() + if pkg_name.lower() == package_name.lower(): + updated_lines.append(f"{pkg_name}=={target_version}") + continue + + updated_lines.append(line) + + updated_content = "\n".join(updated_lines) + + elif file_type == "Cargo.toml": + lines = current_content.split("\n") + updated_lines = [] + + for line in lines: + match = re.match( + r"(\s*)(" + + re.escape(package_name) + + r')\s*=\s*["\']([^"\']+)["\']', + line, + ) + if match: + indent, pkg_name, _ = match.groups() + updated_lines.append(f'{indent}{pkg_name} = "{target_version}"') + else: + updated_lines.append(line) + + updated_content = "\n".join(updated_lines) + + else: + return json.dumps( + {"status": "error", "message": f"Unsupported file type: {file_type}"} + ) + + return json.dumps( + { + "status": "success", + "updated_content": updated_content, + "package": package_name, + "rolled_back_to": target_version, + } + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error rolling back: {str(e)}"} + ) + + +@tool +def parse_error_for_dependency(error_output: str, updated_packages: str) -> str: + """ + Analyze error output to identify which dependency likely caused the failure. + Uses AI to intelligently parse error messages. + + Args: + error_output: Error output from build/test command + updated_packages: JSON string with list of packages that were updated + + Returns: + JSON with identified problematic package and confidence level + """ + try: + packages = json.loads(updated_packages) + package_names = [p["name"] for p in packages] + error_lower = error_output.lower() + + # Heuristic 1: Direct package name match in error output (most common) + matches = [] + for pkg in package_names: + pkg_lower = pkg.lower() + # Count occurrences โ€” more mentions = more likely culprit + count = error_lower.count(pkg_lower) + if count > 0: + matches.append((pkg, count)) + + if matches: + # Sort by frequency, pick the most mentioned package + matches.sort(key=lambda x: x[1], reverse=True) + suspected = matches[0][0] + + # Detect error type from common patterns + error_type = "other" + if re.search(r"(ImportError|ModuleNotFoundError|cannot find module)", error_output): + error_type = "import_error" + elif re.search(r"(TypeError|AttributeError|has no attribute|is not callable)", error_output): + error_type = "api_change" + elif re.search(r"(type error|type mismatch|incompatible types)", error_output, re.IGNORECASE): + error_type = "type_error" + + confidence = "high" if matches[0][1] >= 3 else "medium" + return json.dumps({ + "status": "success", + "analysis": { + "suspected_package": suspected, + "confidence": confidence, + "reasoning": f"Package '{suspected}' appears {matches[0][1]} time(s) in error output", + "error_type": error_type, + }, + }) + + # Heuristic 2: Check for common import/require patterns mentioning packages + import_pattern = re.search( + r"(?:from|import|require\()\s*['\"]?([a-zA-Z0-9_@/.-]+)", error_output + ) + if import_pattern: + mentioned = import_pattern.group(1).split("/")[0].lstrip("@") + for pkg in package_names: + if mentioned.lower() in pkg.lower() or pkg.lower() in mentioned.lower(): + return json.dumps({ + "status": "success", + "analysis": { + "suspected_package": pkg, + "confidence": "medium", + "reasoning": f"Import/require of '{mentioned}' found in error, matches package '{pkg}'", + "error_type": "import_error", + }, + }) + + return json.dumps({ + "status": "success", + "analysis": { + "suspected_package": None, + "confidence": "low", + "reasoning": "Could not identify specific package from error output", + "error_type": "unknown", + }, + }) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error parsing error output: {str(e)}"} + ) + + +@tool +def categorize_updates(outdated_packages: str) -> str: + """ + Categorize dependency updates into major, minor, and patch. + + Args: + outdated_packages: JSON string with outdated packages + + Returns: + JSON with categorized updates + """ + try: + parsed = json.loads(outdated_packages) + + # Handle wrapped formats: {"outdated_dependencies": [...]} or {"packages": [...]} + if isinstance(parsed, dict): + for key in ["outdated_dependencies", "outdated_packages", "packages"]: + if key in parsed and isinstance(parsed[key], list): + parsed = parsed[key] + break + else: + # If it's a dict with no known list key, wrap in a list + if "name" in parsed: + parsed = [parsed] + else: + parsed = [] + + packages = parsed + + major_updates = [] + minor_updates = [] + patch_updates = [] + + for pkg in packages: + name = pkg.get("name", "unknown") + # Accept both "current"/"latest" and "current_version"/"latest_version" + current = pkg.get("current", pkg.get("current_version", "0.0.0")) + latest = pkg.get("latest", pkg.get("latest_version", "0.0.0")) + current = current.lstrip("^~>=v") + latest = latest.lstrip("^~>=v") + + try: + curr_parts = current.split(".") + latest_parts = latest.split(".") + + if len(curr_parts) >= 1 and len(latest_parts) >= 1: + if curr_parts[0] != latest_parts[0]: + major_updates.append(pkg) + elif ( + len(curr_parts) >= 2 + and len(latest_parts) >= 2 + and curr_parts[1] != latest_parts[1] + ): + minor_updates.append(pkg) + else: + patch_updates.append(pkg) + else: + minor_updates.append(pkg) + except (ValueError, IndexError): + minor_updates.append(pkg) + + return json.dumps( + { + "status": "success", + "major": major_updates, + "minor": minor_updates, + "patch": patch_updates, + "counts": { + "major": len(major_updates), + "minor": len(minor_updates), + "patch": len(patch_updates), + }, + } + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error categorizing updates: {str(e)}"} + ) + + +@tool +def get_latest_version_for_major( + package_name: str, major_version: str, package_manager: str +) -> str: + """ + Get the latest version within a specific major version. + + Args: + package_name: Name of the package + major_version: Major version to stay within (e.g., "17" for React 17.x) + package_manager: Package manager (npm, pip, cargo) + + Returns: + JSON with the latest version in that major version line + """ + try: + import subprocess + + if package_manager == "npm": + result = subprocess.run( + ["npm", "view", package_name, "versions", "--json"], + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + versions = json.loads(result.stdout) + if not isinstance(versions, list): + versions = [versions] + + # Filter for major version + matching_versions = [ + v for v in versions if v.split(".")[0] == major_version + ] + + if matching_versions: + # Get the latest + latest = matching_versions[-1] + return json.dumps( + { + "status": "success", + "package": package_name, + "major_version": major_version, + "latest_in_major": latest, + } + ) + + # Fallback or other package managers + return json.dumps( + { + "status": "error", + "message": f"Could not find latest version for {package_name} major {major_version}", + } + ) + + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Error getting version info: {str(e)}"} + ) diff --git a/build/lib/src/utils/__init__.py b/build/lib/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/src/utils/docker.py b/build/lib/src/utils/docker.py new file mode 100644 index 0000000..079392a --- /dev/null +++ b/build/lib/src/utils/docker.py @@ -0,0 +1,137 @@ +"""Docker and container runtime utilities.""" + +import os +import shutil +import subprocess +from typing import Optional + + +def get_docker_path() -> str: + """Get the absolute path to the docker executable. + + Using absolute paths prevents PyCharm debugger issues where it + tries to check if 'docker' is a Python script. + """ + docker_path = shutil.which("docker") + if docker_path: + return docker_path + + # Common Docker paths on different systems + common_paths = [ + "/usr/local/bin/docker", + "/usr/bin/docker", + "/opt/homebrew/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + ] + + for path in common_paths: + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + + return "docker" # Fallback to PATH lookup + + +def find_command_path(command: str) -> Optional[str]: + """ + Find the full path to a command, checking common locations. + + Args: + command: Command name to find (e.g., 'docker', 'podman') + + Returns: + Full path to command if found, None otherwise + """ + # First try using shutil.which (respects PATH) + cmd_path = shutil.which(command) + if cmd_path: + return cmd_path + + # Common installation paths for macOS + common_paths_mac = [ + f"/usr/local/bin/{command}", + f"/opt/homebrew/bin/{command}", + f"/usr/bin/{command}", + f"/opt/local/bin/{command}", + # OrbStack specific paths + f"/Applications/OrbStack.app/Contents/MacOS/{command}", + f"~/.orbstack/bin/{command}", + ] + + # Common installation paths for Linux + common_paths_linux = [ + f"/usr/local/bin/{command}", + f"/usr/bin/{command}", + f"/bin/{command}", + f"/snap/bin/{command}", + f"~/.local/bin/{command}", + ] + + # Try macOS paths first, then Linux + all_paths = common_paths_mac + common_paths_linux + + # Expand ~ and check if file exists + for path in all_paths: + expanded_path = os.path.expanduser(path) + if os.path.isfile(expanded_path) and os.access(expanded_path, os.X_OK): + return expanded_path + + return None + + +def detect_container_runtime() -> str: + """ + Auto-detect available container runtime. + + Checks for container runtimes in order of preference: + 1. docker (Docker Desktop, OrbStack, Rancher Desktop) + 2. podman (Podman Desktop, native Podman) + 3. nerdctl (containerd with nerdctl) + + Returns: + Full path or name of the detected container runtime command + + Raises: + RuntimeError: If no container runtime is found + """ + runtimes = ["docker", "podman", "nerdctl"] + + for runtime in runtimes: + # Try to find the command path + cmd_path = find_command_path(runtime) + + if cmd_path: + # Verify it works by running --version + try: + result = subprocess.run( + [cmd_path, "--version"], capture_output=True, timeout=5, text=True + ) + if result.returncode == 0: + # Always return the full path to avoid PATH issues + # (especially important for PyCharm debugger and subprocess calls) + return cmd_path + except (subprocess.TimeoutExpired, Exception): + continue + + # Provide helpful error message + error_msg = ( + "No container runtime found. Please ensure one of the following is installed:\n" + " - Docker Desktop: https://www.docker.com/products/docker-desktop\n" + " - OrbStack (macOS): https://orbstack.dev/\n" + " - Podman Desktop: https://podman-desktop.io/\n" + " - Rancher Desktop: https://rancherdesktop.io/\n\n" + "If you have OrbStack or Docker installed:\n" + "1. Ensure it's running (open the application)\n" + "2. Verify with: docker --version (in terminal)\n" + "3. If terminal works but Python doesn't, the issue is PATH.\n\n" + "For macOS users, add docker to PATH:\n" + " echo 'export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\"' >> ~/.zshrc\n" + " source ~/.zshrc\n\n" + "Checked locations:\n" + " - $PATH (using shutil.which)\n" + " - /usr/local/bin/docker\n" + " - /opt/homebrew/bin/docker\n" + " - /usr/bin/docker\n" + " - ~/.orbstack/bin/docker\n" + " - /Applications/OrbStack.app/Contents/MacOS/docker" + ) + raise RuntimeError(error_msg) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d1c8240 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pydantic_core==2.42.0 +setuptools==82.0.1 \ No newline at end of file