diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 0940a374..af261350 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -303,6 +303,15 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Setup Flatpak + run: | + set -e + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder + flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 + flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08 + - name: Cache bundled Python uses: actions/cache@v4 with: @@ -328,6 +337,7 @@ jobs: path: | apps/frontend/dist/*.AppImage apps/frontend/dist/*.deb + apps/frontend/dist/*.flatpak apps/frontend/dist/*.yml create-release: @@ -350,12 +360,12 @@ jobs: - name: Flatten and validate artifacts run: | mkdir -p release-assets - find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.yml" \) -exec cp {} release-assets/ \; + find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" \) -exec cp {} release-assets/ \; # Validate that at least one artifact was copied - artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l) + artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l) if [ "$artifact_count" -eq 0 ]; then - echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files." + echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files." exit 1 fi @@ -413,7 +423,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) >> $GITHUB_STEP_SUMMARY + find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml deleted file mode 100644 index 1df6b282..00000000 --- a/.github/workflows/cla.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: "CLA Assistant" - -on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, closed, synchronize] - -permissions: - actions: write - contents: write - pull-requests: write - statuses: write - -jobs: - CLAAssistant: - name: CLA Check - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "CLA Assistant" - if: | - github.event.comment.body == 'recheck' || - github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' || - github.event_name == 'pull_request_target' - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - path-to-signatures: '.github/cla-signatures.json' - path-to-document: 'https://github.com/AndyMik90/Auto-Claude/blob/main/CLA.md' - branch: 'main' - # Allowlist for bots and automation - allowlist: 'dependabot[bot],github-actions[bot],renovate[bot],coderabbitai[bot]' - # Custom messages - custom-notsigned-prcomment: | - Thank you for your contribution! Before we can accept your PR, you need to sign our Contributor License Agreement (CLA). - - **To sign the CLA**, please comment on this PR with exactly: - ``` - I have read the CLA Document and I hereby sign the CLA - ``` - - You can read the full CLA here: [CLA.md](https://github.com/AndyMik90/Auto-Claude/blob/main/CLA.md) - - --- - **Why do we need a CLA?** - - Auto Claude is licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options in the future. - - You retain full copyright ownership of your contributions. - custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA' - custom-allsigned-prcomment: | - All contributors have signed the CLA. Thank you! - lock-pullrequest-aftermerge: false - suggest-recheck: true diff --git a/apps/backend/agents/tools_pkg/models.py b/apps/backend/agents/tools_pkg/models.py index 2529bb8b..c1e5df57 100644 --- a/apps/backend/agents/tools_pkg/models.py +++ b/apps/backend/agents/tools_pkg/models.py @@ -266,6 +266,19 @@ AGENT_CONFIGS = { "auto_claude_tools": [], "thinking_default": "high", }, + "pr_orchestrator_parallel": { + "tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only for parallel PR orchestrator + "mcp_servers": ["context7"], + "auto_claude_tools": [], + "thinking_default": "high", + }, + "pr_followup_parallel": { + "tools": BASE_READ_TOOLS + + WEB_TOOLS, # Read-only for parallel followup reviewer + "mcp_servers": ["context7"], + "auto_claude_tools": [], + "thinking_default": "high", + }, # ═══════════════════════════════════════════════════════════════════════ # ANALYSIS PHASES # ═══════════════════════════════════════════════════════════════════════ @@ -337,10 +350,46 @@ def get_agent_config(agent_type: str) -> dict: return AGENT_CONFIGS[agent_type] +def _map_mcp_server_name( + name: str, custom_server_ids: list[str] | None = None +) -> str | None: + """ + Map user-friendly MCP server names to internal identifiers. + Also accepts custom server IDs directly. + + Args: + name: User-provided MCP server name + custom_server_ids: List of custom server IDs to accept as-is + + Returns: + Internal server identifier or None if not recognized + """ + if not name: + return None + mappings = { + "context7": "context7", + "graphiti-memory": "graphiti", + "graphiti": "graphiti", + "linear": "linear", + "electron": "electron", + "puppeteer": "puppeteer", + "auto-claude": "auto-claude", + } + # Check if it's a known mapping + mapped = mappings.get(name.lower().strip()) + if mapped: + return mapped + # Check if it's a custom server ID (accept as-is) + if custom_server_ids and name in custom_server_ids: + return name + return None + + def get_required_mcp_servers( agent_type: str, project_capabilities: dict | None = None, linear_enabled: bool = False, + mcp_config: dict | None = None, ) -> list[str]: """ Get MCP servers required for this agent type. @@ -349,11 +398,16 @@ def get_required_mcp_servers( - "browser" → electron (if is_electron) or puppeteer (if is_web_frontend) - "linear" → only if in mcp_servers_optional AND linear_enabled is True - "graphiti" → only if GRAPHITI_MCP_URL is set + - Respects per-project MCP config overrides from .auto-claude/.env + - Applies per-agent ADD/REMOVE overrides from AGENT_MCP__ADD/REMOVE Args: agent_type: The agent type identifier project_capabilities: Dict from detect_project_capabilities() or None linear_enabled: Whether Linear integration is enabled for this project + mcp_config: Per-project MCP server toggles from .auto-claude/.env + Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED, + PUPPETEER_MCP_ENABLED, AGENT_MCP__ADD/REMOVE Returns: List of MCP server names to start @@ -361,28 +415,80 @@ def get_required_mcp_servers( config = get_agent_config(agent_type) servers = list(config.get("mcp_servers", [])) + # Load per-project config (or use defaults) + if mcp_config is None: + mcp_config = {} + + # Filter context7 if explicitly disabled by project config + if "context7" in servers: + context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true") + if str(context7_enabled).lower() == "false": + servers = [s for s in servers if s != "context7"] + # Handle optional servers (e.g., Linear if project setting enabled) optional = config.get("mcp_servers_optional", []) if "linear" in optional and linear_enabled: - servers.append("linear") + # Also check per-project LINEAR_MCP_ENABLED override + linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true") + if str(linear_mcp_enabled).lower() != "false": + servers.append("linear") - # Handle dynamic "browser" → electron/puppeteer based on project type + # Handle dynamic "browser" → electron/puppeteer based on project type and config if "browser" in servers: servers = [s for s in servers if s != "browser"] if project_capabilities: is_electron = project_capabilities.get("is_electron", False) is_web_frontend = project_capabilities.get("is_web_frontend", False) - if is_electron and is_electron_mcp_enabled(): + # Check per-project overrides (default false for both) + electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false") + puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false") + + # Electron: enabled by project config OR global env var + if is_electron and ( + str(electron_enabled).lower() == "true" or is_electron_mcp_enabled() + ): servers.append("electron") + # Puppeteer: enabled by project config (no global env var) elif is_web_frontend and not is_electron: - servers.append("puppeteer") + if str(puppeteer_enabled).lower() == "true": + servers.append("puppeteer") # Filter graphiti if not enabled if "graphiti" in servers: if not os.environ.get("GRAPHITI_MCP_URL"): servers = [s for s in servers if s != "graphiti"] + # ========== Apply per-agent MCP overrides ========== + # Format: AGENT_MCP__ADD=server1,server2 + # AGENT_MCP__REMOVE=server1,server2 + add_key = f"AGENT_MCP_{agent_type}_ADD" + remove_key = f"AGENT_MCP_{agent_type}_REMOVE" + + # Extract custom server IDs for mapping (allows custom servers to be recognized) + custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", []) + custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")] + + # Process additions + if add_key in mcp_config: + additions = [ + s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip() + ] + for server in additions: + mapped = _map_mcp_server_name(server, custom_server_ids) + if mapped and mapped not in servers: + servers.append(mapped) + + # Process removals (but never remove auto-claude) + if remove_key in mcp_config: + removals = [ + s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip() + ] + for server in removals: + mapped = _map_mcp_server_name(server, custom_server_ids) + if mapped and mapped != "auto-claude": # auto-claude cannot be removed + servers = [s for s in servers if s != mapped] + return servers diff --git a/apps/backend/agents/tools_pkg/permissions.py b/apps/backend/agents/tools_pkg/permissions.py index c9cb48e1..af076e51 100644 --- a/apps/backend/agents/tools_pkg/permissions.py +++ b/apps/backend/agents/tools_pkg/permissions.py @@ -31,6 +31,7 @@ def get_allowed_tools( agent_type: str, project_capabilities: dict | None = None, linear_enabled: bool = False, + mcp_config: dict | None = None, ) -> list[str]: """ Get the list of allowed tools for a specific agent type. @@ -46,6 +47,7 @@ def get_allowed_tools( project_capabilities: Optional dict from detect_project_capabilities() containing flags like is_electron, is_web_frontend, etc. linear_enabled: Whether Linear integration is enabled for this project + mcp_config: Per-project MCP server toggles from .auto-claude/.env Returns: List of allowed tool names @@ -64,6 +66,7 @@ def get_allowed_tools( agent_type, project_capabilities, linear_enabled, + mcp_config, ) # Add auto-claude tools ONLY if the MCP server is available diff --git a/apps/backend/commit_message.py b/apps/backend/commit_message.py index 9742868d..0518f20f 100644 --- a/apps/backend/commit_message.py +++ b/apps/backend/commit_message.py @@ -186,9 +186,15 @@ Fixes #N (if applicable)""" return prompt -async def _call_claude_haiku(prompt: str) -> str: - """Call Claude Haiku with low thinking for fast commit message generation.""" +async def _call_claude(prompt: str) -> str: + """Call Claude for commit message generation. + + Reads model/thinking settings from environment variables: + - UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001") + - UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024") + """ from core.auth import ensure_claude_code_oauth_token, get_auth_token + from core.model_config import get_utility_model_config if not get_auth_token(): logger.warning("No authentication token found") @@ -202,11 +208,18 @@ async def _call_claude_haiku(prompt: str) -> str: logger.warning("core.simple_client not available") return "" + # Get model settings from environment (passed from frontend) + model, thinking_budget = get_utility_model_config() + + logger.info( + f"Commit message using model={model}, thinking_budget={thinking_budget}" + ) + client = create_simple_client( agent_type="commit_message", - model="claude-haiku-4-5-20251001", + model=model, system_prompt=SYSTEM_PROMPT, - max_thinking_tokens=1024, # Low thinking for speed + max_thinking_tokens=thinking_budget, ) try: @@ -284,11 +297,9 @@ def generate_commit_message_sync( import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as pool: - result = pool.submit( - lambda: asyncio.run(_call_claude_haiku(prompt)) - ).result() + result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result() else: - result = asyncio.run(_call_claude_haiku(prompt)) + result = asyncio.run(_call_claude(prompt)) if result: return result @@ -350,7 +361,7 @@ async def generate_commit_message( # Call Claude try: - result = await _call_claude_haiku(prompt) + result = await _call_claude(prompt) if result: return result except Exception as e: diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 16d6d05b..7a66b121 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -13,9 +13,12 @@ single source of truth for phase-aware tool and MCP server configuration. """ import json +import logging import os from pathlib import Path +logger = logging.getLogger(__name__) + from agents.tools_pkg import ( CONTEXT7_TOOLS, ELECTRON_TOOLS, @@ -35,6 +38,245 @@ from prompts_pkg.project_context import detect_project_capabilities, load_projec from security import bash_security_hook +def _validate_custom_mcp_server(server: dict) -> bool: + """ + Validate a custom MCP server configuration for security. + + Ensures only expected fields with valid types are present. + Rejects configurations that could lead to command injection. + + Args: + server: Dict representing a custom MCP server configuration + + Returns: + True if valid, False otherwise + """ + if not isinstance(server, dict): + return False + + # Required fields + required_fields = {"id", "name", "type"} + if not all(field in server for field in required_fields): + logger.warning( + f"Custom MCP server missing required fields: {required_fields - server.keys()}" + ) + return False + + # Validate field types + if not isinstance(server.get("id"), str) or not server["id"]: + return False + if not isinstance(server.get("name"), str) or not server["name"]: + return False + # FIX: Changed from ('command', 'url') to ('command', 'http') to match actual usage + if server.get("type") not in ("command", "http"): + logger.warning(f"Invalid MCP server type: {server.get('type')}") + return False + + # Allowlist of safe executable commands for MCP servers + # Only allow known package managers and interpreters - NO shell commands + SAFE_COMMANDS = { + "npx", + "npm", + "node", + "python", + "python3", + "uv", + "uvx", + } + + # Blocklist of dangerous shell commands that should never be allowed + DANGEROUS_COMMANDS = { + "bash", + "sh", + "cmd", + "powershell", + "pwsh", # PowerShell Core + "/bin/bash", + "/bin/sh", + "/bin/zsh", + "/usr/bin/bash", + "/usr/bin/sh", + "zsh", + "fish", + } + + # Dangerous interpreter flags that allow arbitrary code execution + # Covers Python (-e, -c, -m, -p), Node.js (--eval, --print, loaders), and general + DANGEROUS_FLAGS = { + "--eval", + "-e", + "-c", + "--exec", + "-m", # Python module execution + "-p", # Python eval+print + "--print", # Node.js print + "--input-type=module", # Node.js ES module mode + "--experimental-loader", # Node.js custom loaders + "--require", # Node.js require injection + "-r", # Node.js require shorthand + } + + # Type-specific validation + if server["type"] == "command": + if not isinstance(server.get("command"), str) or not server["command"]: + logger.warning("Command-type MCP server missing 'command' field") + return False + + # SECURITY FIX: Validate command is in safe list and not in dangerous list + command = server.get("command", "") + + # Reject paths - commands must be bare names only (no / or \) + # This prevents path traversal like '/custom/malicious' or './evil' + if "/" in command or "\\" in command: + logger.warning( + f"Rejected command with path in MCP server: {command}. " + f"Commands must be bare names without path separators." + ) + return False + + if command in DANGEROUS_COMMANDS: + logger.warning( + f"Rejected dangerous command in MCP server: {command}. " + f"Shell commands are not allowed for security reasons." + ) + return False + + if command not in SAFE_COMMANDS: + logger.warning( + f"Rejected unknown command in MCP server: {command}. " + f"Only allowed commands: {', '.join(sorted(SAFE_COMMANDS))}" + ) + return False + + # Validate args is a list of strings if present + if "args" in server: + if not isinstance(server["args"], list): + return False + if not all(isinstance(arg, str) for arg in server["args"]): + return False + # Check for dangerous interpreter flags that allow code execution + for arg in server["args"]: + if arg in DANGEROUS_FLAGS: + logger.warning( + f"Rejected dangerous flag '{arg}' in MCP server args. " + f"Interpreter code execution flags are not allowed." + ) + return False + elif server["type"] == "http": + if not isinstance(server.get("url"), str) or not server["url"]: + logger.warning("HTTP-type MCP server missing 'url' field") + return False + # Validate headers is a dict of strings if present + if "headers" in server: + if not isinstance(server["headers"], dict): + return False + if not all( + isinstance(k, str) and isinstance(v, str) + for k, v in server["headers"].items() + ): + return False + + # Optional description must be string if present + if "description" in server and not isinstance(server.get("description"), str): + return False + + # Reject any unexpected fields that could be exploited + allowed_fields = { + "id", + "name", + "type", + "command", + "args", + "url", + "headers", + "description", + } + unexpected_fields = set(server.keys()) - allowed_fields + if unexpected_fields: + logger.warning(f"Custom MCP server has unexpected fields: {unexpected_fields}") + return False + + return True + + +def load_project_mcp_config(project_dir: Path) -> dict: + """ + Load MCP configuration from project's .auto-claude/.env file. + + Returns a dict of MCP-related env vars: + - CONTEXT7_ENABLED (default: true) + - LINEAR_MCP_ENABLED (default: true) + - ELECTRON_MCP_ENABLED (default: false) + - PUPPETEER_MCP_ENABLED (default: false) + - AGENT_MCP__ADD (per-agent MCP additions) + - AGENT_MCP__REMOVE (per-agent MCP removals) + - CUSTOM_MCP_SERVERS (JSON array of custom server configs) + + Args: + project_dir: Path to the project directory + + Returns: + Dict of MCP configuration values (string values, except CUSTOM_MCP_SERVERS which is parsed JSON) + """ + env_path = project_dir / ".auto-claude" / ".env" + if not env_path.exists(): + return {} + + config = {} + mcp_keys = { + "CONTEXT7_ENABLED", + "LINEAR_MCP_ENABLED", + "ELECTRON_MCP_ENABLED", + "PUPPETEER_MCP_ENABLED", + } + + try: + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("\"'") + # Include global MCP toggles + if key in mcp_keys: + config[key] = value + # Include per-agent MCP overrides (AGENT_MCP__ADD/REMOVE) + elif key.startswith("AGENT_MCP_"): + config[key] = value + # Include custom MCP servers (parse JSON with schema validation) + elif key == "CUSTOM_MCP_SERVERS": + try: + parsed = json.loads(value) + if not isinstance(parsed, list): + logger.warning( + "CUSTOM_MCP_SERVERS must be a JSON array" + ) + config["CUSTOM_MCP_SERVERS"] = [] + else: + # Validate each server and filter out invalid ones + valid_servers = [] + for i, server in enumerate(parsed): + if _validate_custom_mcp_server(server): + valid_servers.append(server) + else: + logger.warning( + f"Skipping invalid custom MCP server at index {i}" + ) + config["CUSTOM_MCP_SERVERS"] = valid_servers + except json.JSONDecodeError: + logger.warning( + f"Failed to parse CUSTOM_MCP_SERVERS JSON: {value}" + ) + config["CUSTOM_MCP_SERVERS"] = [] + except Exception as e: + logger.debug(f"Failed to load project MCP config from {env_path}: {e}") + + return config + + def is_graphiti_mcp_enabled() -> bool: """ Check if Graphiti MCP server integration is enabled. @@ -97,6 +339,7 @@ def create_client( agent_type: str = "coder", max_thinking_tokens: int | None = None, output_format: dict | None = None, + agents: dict | None = None, ) -> ClaudeSDKClient: """ Create a Claude Agent SDK client with multi-layered security. @@ -119,6 +362,10 @@ def create_client( output_format: Optional structured output format for validated JSON responses. Use {"type": "json_schema", "schema": Model.model_json_schema()} See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs + agents: Optional dict of subagent definitions for SDK parallel execution. + Format: {"agent-name": {"description": "...", "prompt": "...", + "tools": [...], "model": "inherit"}} + See: https://platform.claude.com/docs/en/agent-sdk/subagents Returns: Configured ClaudeSDKClient @@ -152,20 +399,27 @@ def create_client( project_index = load_project_index(project_dir) project_capabilities = detect_project_capabilities(project_index) + # Load per-project MCP configuration from .auto-claude/.env + mcp_config = load_project_mcp_config(project_dir) + # Get allowed tools using phase-aware configuration # This respects AGENT_CONFIGS and only includes tools the agent needs + # Also respects per-project MCP configuration allowed_tools_list = get_allowed_tools( agent_type, project_capabilities, linear_enabled, + mcp_config, ) # Get required MCP servers for this agent type # This is the key optimization - only start servers the agent needs + # Now also respects per-project MCP configuration required_servers = get_required_mcp_servers( agent_type, project_capabilities, linear_enabled, + mcp_config, ) # Check if Graphiti MCP is enabled (already filtered by get_required_mcp_servers) @@ -323,6 +577,30 @@ def create_client( if auto_claude_mcp_server: mcp_servers["auto-claude"] = auto_claude_mcp_server + # Add custom MCP servers from project config + custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", []) + for custom in custom_servers: + server_id = custom.get("id") + if not server_id: + continue + # Only include if agent has it in their effective server list + if server_id not in required_servers: + continue + server_type = custom.get("type", "command") + if server_type == "command": + mcp_servers[server_id] = { + "command": custom.get("command", "npx"), + "args": custom.get("args", []), + } + elif server_type == "http": + server_config = { + "type": "http", + "url": custom.get("url", ""), + } + if custom.get("headers"): + server_config["headers"] = custom["headers"] + mcp_servers[server_id] = server_config + # Build system prompt base_prompt = ( f"You are an expert full-stack developer building production-quality software. " @@ -370,4 +648,9 @@ def create_client( if output_format: options_kwargs["output_format"] = output_format + # Add subagent definitions if specified + # See: https://platform.claude.com/docs/en/agent-sdk/subagents + if agents: + options_kwargs["agents"] = agents + return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs)) diff --git a/apps/backend/core/model_config.py b/apps/backend/core/model_config.py new file mode 100644 index 00000000..41f3bb8f --- /dev/null +++ b/apps/backend/core/model_config.py @@ -0,0 +1,68 @@ +""" +Model Configuration Utilities +============================== + +Shared utilities for reading and parsing model configuration from environment variables. +Used by both commit_message.py and merge resolver. +""" + +import logging +import os + +logger = logging.getLogger(__name__) + +# Default model for utility operations (commit messages, merge resolution) +DEFAULT_UTILITY_MODEL = "claude-haiku-4-5-20251001" + + +def get_utility_model_config( + default_model: str = DEFAULT_UTILITY_MODEL, +) -> tuple[str, int | None]: + """ + Get utility model configuration from environment variables. + + Reads UTILITY_MODEL_ID and UTILITY_THINKING_BUDGET from environment, + with sensible defaults and validation. + + Args: + default_model: Default model ID to use if UTILITY_MODEL_ID not set + + Returns: + Tuple of (model_id, thinking_budget) where thinking_budget is None + if extended thinking is disabled, or an int representing token budget + """ + model = os.environ.get("UTILITY_MODEL_ID", default_model) + thinking_budget_str = os.environ.get("UTILITY_THINKING_BUDGET", "") + + # Parse thinking budget: empty string = disabled (None), number = budget tokens + # Note: 0 is treated as "disable thinking" (same as None) since 0 tokens is meaningless + thinking_budget: int | None + if not thinking_budget_str: + # Empty string means "none" level - disable extended thinking + thinking_budget = None + else: + try: + parsed_budget = int(thinking_budget_str) + # Validate positive values - 0 or negative are invalid + # 0 would mean "thinking enabled but 0 tokens" which is meaningless + if parsed_budget <= 0: + if parsed_budget == 0: + # Zero means disable thinking (same as empty string) + logger.debug( + "UTILITY_THINKING_BUDGET=0 interpreted as 'disable thinking'" + ) + thinking_budget = None + else: + logger.warning( + f"Negative UTILITY_THINKING_BUDGET value '{thinking_budget_str}' not allowed, using default 1024" + ) + thinking_budget = 1024 + else: + thinking_budget = parsed_budget + except ValueError: + logger.warning( + f"Invalid UTILITY_THINKING_BUDGET value '{thinking_budget_str}', using default 1024" + ) + thinking_budget = 1024 + + return model, thinking_budget diff --git a/apps/backend/merge/ai_resolver/claude_client.py b/apps/backend/merge/ai_resolver/claude_client.py index 35a57ed0..77229043 100644 --- a/apps/backend/merge/ai_resolver/claude_client.py +++ b/apps/backend/merge/ai_resolver/claude_client.py @@ -26,12 +26,16 @@ def create_claude_resolver() -> AIResolver: Create an AIResolver configured to use Claude via the Agent SDK. Uses the same OAuth token pattern as the rest of the auto-claude framework. + Reads model/thinking settings from environment variables: + - UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001") + - UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024") Returns: Configured AIResolver instance """ # Import here to avoid circular dependency from core.auth import ensure_claude_code_oauth_token, get_auth_token + from core.model_config import get_utility_model_config from .resolver import AIResolver @@ -48,6 +52,13 @@ def create_claude_resolver() -> AIResolver: logger.warning("core.simple_client not available, AI resolution unavailable") return AIResolver() + # Get model settings from environment (passed from frontend) + model, thinking_budget = get_utility_model_config() + + logger.info( + f"Merge resolver using model={model}, thinking_budget={thinking_budget}" + ) + def call_claude(system: str, user: str) -> str: """Call Claude using the Agent SDK for merge resolution.""" @@ -55,8 +66,9 @@ def create_claude_resolver() -> AIResolver: # Create a minimal client for merge resolution client = create_simple_client( agent_type="merge_resolver", - model="sonnet", + model=model, system_prompt=system, + max_thinking_tokens=thinking_budget, ) try: diff --git a/apps/backend/prompts/github/pr_codebase_fit_agent.md b/apps/backend/prompts/github/pr_codebase_fit_agent.md new file mode 100644 index 00000000..f9e14e1e --- /dev/null +++ b/apps/backend/prompts/github/pr_codebase_fit_agent.md @@ -0,0 +1,203 @@ +# Codebase Fit Review Agent + +You are a focused codebase fit review agent. You have been spawned by the orchestrating agent to verify that new code fits well within the existing codebase, follows established patterns, and doesn't reinvent existing functionality. + +## Your Mission + +Ensure new code integrates well with the existing codebase. Check for consistency with project conventions, reuse of existing utilities, and architectural alignment. Focus ONLY on codebase fit - not security, logic correctness, or general quality. + +## Codebase Fit Focus Areas + +### 1. Naming Conventions +- **Inconsistent Naming**: Using `camelCase` when project uses `snake_case` +- **Different Terminology**: Using `user` when codebase uses `account` +- **Abbreviation Mismatch**: Using `usr` when codebase spells out `user` +- **File Naming**: `MyComponent.tsx` vs `my-component.tsx` vs `myComponent.tsx` +- **Directory Structure**: Placing files in wrong directories + +### 2. Pattern Adherence +- **Framework Patterns**: Not following React hooks pattern, Django views pattern, etc. +- **Project Patterns**: Not following established error handling, logging, or API patterns +- **Architectural Patterns**: Violating layer separation (e.g., business logic in controllers) +- **State Management**: Using different state management approach than established +- **Configuration Patterns**: Different config file format or location + +### 3. Ecosystem Fit +- **Reinventing Utilities**: Writing new helper when similar one exists +- **Duplicate Functionality**: Adding code that duplicates existing implementation +- **Ignoring Shared Code**: Not using established shared components/utilities +- **Wrong Abstraction Level**: Creating too specific or too generic solutions +- **Missing Integration**: Not integrating with existing systems (logging, metrics, etc.) + +### 4. Architectural Consistency +- **Layer Violations**: Calling database directly from UI components +- **Dependency Direction**: Wrong dependency direction between modules +- **Module Boundaries**: Crossing module boundaries inappropriately +- **API Contracts**: Breaking established API patterns +- **Data Flow**: Different data flow pattern than established + +### 5. Monolithic File Detection +- **Large Files**: Files exceeding 500 lines (should be split) +- **God Objects**: Classes/modules doing too many unrelated things +- **Mixed Concerns**: UI, business logic, and data access in same file +- **Excessive Exports**: Files exporting too many unrelated items + +### 6. Import/Dependency Patterns +- **Import Style**: Relative vs absolute imports, import grouping +- **Circular Dependencies**: Creating import cycles +- **Unused Imports**: Adding imports that aren't used +- **Dependency Injection**: Not following DI patterns when established + +## Review Guidelines + +### High Confidence Only +- Only report findings with **>80% confidence** +- Verify pattern exists in codebase before flagging deviation +- Consider if "inconsistency" might be intentional improvement + +### Severity Classification (All block merge except LOW) +- **CRITICAL** (Blocker): Architectural violation that will cause maintenance problems + - Example: Tight coupling that makes testing impossible + - **Blocks merge: YES** +- **HIGH** (Required): Significant deviation from established patterns + - Example: Reimplementing existing utility, wrong directory structure + - **Blocks merge: YES** +- **MEDIUM** (Recommended): Inconsistency that affects maintainability + - Example: Different naming convention, unused existing helper + - **Blocks merge: YES** (AI fixes quickly, so be strict about quality) +- **LOW** (Suggestion): Minor convention deviation + - Example: Different import ordering, minor naming variation + - **Blocks merge: NO** (optional polish) + +### Check Before Reporting +Before flagging a "should use existing utility" issue: +1. Verify the existing utility actually does what the new code needs +2. Check if existing utility has the right signature/behavior +3. Consider if the new implementation is intentionally different + +## Code Patterns to Flag + +### Reinventing Existing Utilities +```javascript +// If codebase has: src/utils/format.ts with formatDate() +// Flag this: +function formatDateString(date) { + return `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`; +} +// Should use: import { formatDate } from '@/utils/format'; +``` + +### Naming Convention Violations +```python +# If codebase uses snake_case: +def getUserById(user_id): # Should be: get_user_by_id + ... + +# If codebase uses specific terminology: +class Customer: # Should be: User (if that's the codebase term) + ... +``` + +### Architectural Violations +```typescript +// If codebase separates concerns: +// In UI component: +const users = await db.query('SELECT * FROM users'); // BAD +// Should use: const users = await userService.getAll(); + +// If codebase has established API patterns: +app.get('/user', ...) // BAD: singular +app.get('/users', ...) // GOOD: matches codebase plural pattern +``` + +### Monolithic Files +```typescript +// File with 800 lines doing: +// - API handlers +// - Business logic +// - Database queries +// - Utility functions +// Should be split into separate files per concern +``` + +### Import Pattern Violations +```javascript +// If codebase uses absolute imports: +import { User } from '../../../models/user'; // BAD +import { User } from '@/models/user'; // GOOD + +// If codebase groups imports: +// 1. External packages +// 2. Internal modules +// 3. Relative imports +``` + +## Output Format + +Provide findings in JSON format: + +```json +[ + { + "file": "src/components/UserCard.tsx", + "line": 15, + "title": "Reinventing existing date formatting utility", + "description": "This file implements custom date formatting, but the codebase already has `formatDate()` in `src/utils/date.ts` that does the same thing.", + "category": "codebase_fit", + "severity": "high", + "existing_code": "src/utils/date.ts:formatDate()", + "suggested_fix": "Replace custom implementation with: import { formatDate } from '@/utils/date';", + "confidence": 92 + }, + { + "file": "src/api/customers.ts", + "line": 1, + "title": "File uses 'customer' but codebase uses 'user'", + "description": "This file uses 'customer' terminology but the rest of the codebase consistently uses 'user'. This creates confusion and makes search/navigation harder.", + "category": "codebase_fit", + "severity": "medium", + "codebase_pattern": "src/models/user.ts, src/api/users.ts, src/services/userService.ts", + "suggested_fix": "Rename to use 'user' terminology to match codebase conventions", + "confidence": 88 + }, + { + "file": "src/services/orderProcessor.ts", + "line": 1, + "title": "Monolithic file exceeds 500 lines", + "description": "This file is 847 lines and contains order validation, payment processing, inventory management, and notification sending. Each should be separate.", + "category": "codebase_fit", + "severity": "high", + "current_lines": 847, + "suggested_fix": "Split into: orderValidator.ts, paymentProcessor.ts, inventoryManager.ts, notificationService.ts", + "confidence": 95 + } +] +``` + +## Important Notes + +1. **Verify Existing Code**: Before flagging "use existing", verify the existing code actually fits +2. **Check Codebase Patterns**: Look at multiple files to confirm a pattern exists +3. **Consider Evolution**: Sometimes new code is intentionally better than existing patterns +4. **Respect Domain Boundaries**: Different domains might have different conventions +5. **Focus on Changed Files**: Don't audit the entire codebase, focus on new/modified code + +## What NOT to Report + +- Security issues (handled by security agent) +- Logic correctness (handled by logic agent) +- Code quality metrics (handled by quality agent) +- Personal preferences about patterns +- Style issues covered by linters +- Test files that intentionally have different structure + +## Codebase Analysis Tips + +When analyzing codebase fit, look at: +1. **Similar Files**: How are other similar files structured? +2. **Shared Utilities**: What's in `utils/`, `helpers/`, `shared/`? +3. **Naming Patterns**: What naming style do existing files use? +4. **Directory Structure**: Where do similar files live? +5. **Import Patterns**: How do other files import dependencies? + +Focus on **codebase consistency** - new code fitting seamlessly with existing code. diff --git a/apps/backend/prompts/github/pr_followup.md b/apps/backend/prompts/github/pr_followup.md index 8bc1ad3e..1e2fe04e 100644 --- a/apps/backend/prompts/github/pr_followup.md +++ b/apps/backend/prompts/github/pr_followup.md @@ -103,14 +103,16 @@ For important unaddressed comments, create a finding: ### Phase 4: Merge Readiness Assessment -Determine the verdict based on: +Determine the verdict based on (Strict Quality Gates - MEDIUM also blocks): | Verdict | Criteria | |---------|----------| -| **READY_TO_MERGE** | All previous findings resolved, no new critical/high issues, tests pass | -| **MERGE_WITH_CHANGES** | Previous findings resolved, only new medium/low issues remain | -| **NEEDS_REVISION** | Some high-severity issues unresolved or new high issues found | -| **BLOCKED** | Critical issues unresolved or new critical issues introduced | +| **READY_TO_MERGE** | All previous findings resolved, no new issues, tests pass | +| **MERGE_WITH_CHANGES** | Previous findings resolved, only new LOW severity suggestions remain | +| **NEEDS_REVISION** | HIGH or MEDIUM severity issues unresolved, or new HIGH/MEDIUM issues found | +| **BLOCKED** | CRITICAL issues unresolved or new CRITICAL issues introduced | + +Note: Both HIGH and MEDIUM block merge - AI fixes quickly, so be strict about quality. ## Output Format diff --git a/apps/backend/prompts/github/pr_followup_comment_agent.md b/apps/backend/prompts/github/pr_followup_comment_agent.md new file mode 100644 index 00000000..7c45ac59 --- /dev/null +++ b/apps/backend/prompts/github/pr_followup_comment_agent.md @@ -0,0 +1,183 @@ +# Comment Analysis Agent (Follow-up) + +You are a specialized agent for analyzing comments and reviews posted since the last PR review. You have been spawned by the orchestrating agent to process feedback from contributors and AI tools. + +## Your Mission + +1. Analyze contributor comments for questions and concerns +2. Triage AI tool reviews (CodeRabbit, Cursor, Gemini, etc.) +3. Identify issues that need addressing before merge +4. Flag unanswered questions + +## Comment Sources + +### Contributor Comments +- Direct questions about implementation +- Concerns about approach +- Suggestions for improvement +- Approval or rejection signals + +### AI Tool Reviews +Common AI reviewers you'll encounter: +- **CodeRabbit**: Comprehensive code analysis +- **Cursor**: AI-assisted review comments +- **Gemini Code Assist**: Google's code reviewer +- **GitHub Copilot**: Inline suggestions +- **Greptile**: Codebase-aware analysis +- **SonarCloud**: Static analysis findings +- **Snyk**: Security scanning results + +## Analysis Framework + +### For Each Comment + +1. **Identify the author** + - Is this a human contributor or AI bot? + - What's their role (maintainer, contributor, reviewer)? + +2. **Classify sentiment** + - question: Asking for clarification + - concern: Expressing worry about approach + - suggestion: Proposing alternative + - praise: Positive feedback + - neutral: Informational only + +3. **Assess urgency** + - Does this block merge? + - Is a response required? + - What action is needed? + +4. **Extract actionable items** + - What specific change is requested? + - Is the concern valid? + - How should it be addressed? + +## Triage AI Tool Comments + +### Critical (Must Address) +- Security vulnerabilities flagged +- Data loss risks +- Authentication bypasses +- Injection vulnerabilities + +### Important (Should Address) +- Logic errors in core paths +- Missing error handling +- Race conditions +- Resource leaks + +### Nice-to-Have (Consider) +- Code style suggestions +- Performance optimizations +- Documentation improvements + +### False Positive (Dismiss) +- Incorrect analysis +- Not applicable to this context +- Already addressed +- Stylistic preferences + +## Output Format + +### Comment Analyses + +```json +[ + { + "comment_id": "IC-12345", + "author": "maintainer-jane", + "is_ai_bot": false, + "requires_response": true, + "sentiment": "question", + "summary": "Asks why async/await was chosen over callbacks", + "action_needed": "Respond explaining the async choice for better error handling" + }, + { + "comment_id": "RC-67890", + "author": "coderabbitai[bot]", + "is_ai_bot": true, + "requires_response": false, + "sentiment": "suggestion", + "summary": "Suggests using optional chaining for null safety", + "action_needed": null + } +] +``` + +### Comment Findings (Issues from Comments) + +When AI tools or contributors identify real issues: + +```json +[ + { + "id": "CMT-001", + "file": "src/api/handler.py", + "line": 89, + "title": "Unhandled exception in error path (from CodeRabbit)", + "description": "CodeRabbit correctly identified that the except block at line 89 catches Exception but doesn't log or handle it properly.", + "category": "quality", + "severity": "medium", + "confidence": 0.85, + "suggested_fix": "Add proper logging and re-raise or handle the exception appropriately", + "fixable": true, + "source_agent": "comment-analyzer", + "related_to_previous": null + } +] +``` + +## Prioritization Rules + +1. **Maintainer comments** > Contributor comments > AI bot comments +2. **Questions from humans** always require response +3. **Security issues from AI** should be verified and escalated +4. **Repeated concerns** (same issue from multiple sources) are higher priority + +## What to Flag + +### Must Flag +- Unanswered questions from maintainers +- Unaddressed security findings from AI tools +- Explicit change requests not yet implemented +- Blocking concerns from reviewers + +### Should Flag +- Valid suggestions not yet addressed +- Questions about implementation approach +- Concerns about test coverage + +### Can Skip +- Resolved discussions +- Acknowledged but deferred items +- Style-only suggestions +- Clearly false positive AI findings + +## Identifying AI Bots + +Common bot patterns: +- `*[bot]` suffix (e.g., `coderabbitai[bot]`) +- `*-bot` suffix +- Known bot names: dependabot, renovate, snyk-bot, sonarcloud +- Automated review format (structured markdown) + +## Important Notes + +1. **Humans first**: Prioritize human feedback over AI suggestions +2. **Context matters**: Consider the discussion thread, not just individual comments +3. **Don't duplicate**: If an issue is already in previous findings, reference it +4. **Be constructive**: Extract actionable items, not just concerns +5. **Verify AI findings**: AI tools can be wrong - assess validity + +## Sample Workflow + +1. Collect all comments since last review timestamp +2. Separate by source (contributor vs AI bot) +3. For each contributor comment: + - Classify sentiment and urgency + - Check if response/action is needed +4. For each AI review: + - Triage by severity + - Verify if finding is valid + - Check if already addressed in new code +5. Generate comment_analyses and comment_findings lists diff --git a/apps/backend/prompts/github/pr_followup_newcode_agent.md b/apps/backend/prompts/github/pr_followup_newcode_agent.md new file mode 100644 index 00000000..c35e84f8 --- /dev/null +++ b/apps/backend/prompts/github/pr_followup_newcode_agent.md @@ -0,0 +1,162 @@ +# New Code Review Agent (Follow-up) + +You are a specialized agent for reviewing new code added since the last PR review. You have been spawned by the orchestrating agent to identify issues in recently added changes. + +## Your Mission + +Review the incremental diff for: +1. Security vulnerabilities +2. Logic errors and edge cases +3. Code quality issues +4. Potential regressions +5. Incomplete implementations + +## Focus Areas + +Since this is a follow-up review, focus on: +- **New code only**: Don't re-review unchanged code +- **Fix quality**: Are the fixes implemented correctly? +- **Regressions**: Did fixes break other things? +- **Incomplete work**: Are there TODOs or unfinished sections? + +## Review Categories + +### Security (category: "security") +- New injection vulnerabilities (SQL, XSS, command) +- Hardcoded secrets or credentials +- Authentication/authorization gaps +- Insecure data handling + +### Logic (category: "logic") +- Off-by-one errors +- Null/undefined handling +- Race conditions +- Incorrect boundary checks +- State management issues + +### Quality (category: "quality") +- Error handling gaps +- Resource leaks +- Performance anti-patterns +- Code duplication + +### Regression (category: "regression") +- Fixes that break existing behavior +- Removed functionality without replacement +- Changed APIs without updating callers +- Tests that no longer pass + +### Incomplete Fix (category: "incomplete_fix") +- Partial implementations +- TODO comments left in code +- Error paths not handled +- Missing test coverage for fix + +## Severity Guidelines + +### CRITICAL +- Security vulnerabilities exploitable in production +- Data corruption or loss risks +- Complete feature breakage + +### HIGH +- Security issues requiring specific conditions +- Logic errors affecting core functionality +- Regressions in important features + +### MEDIUM +- Code quality issues affecting maintainability +- Minor logic issues in edge cases +- Missing error handling + +### LOW +- Style inconsistencies +- Minor optimizations +- Documentation gaps + +## Confidence Scoring + +Rate confidence (0.0-1.0) based on: +- **>0.9**: Obvious, verifiable issue +- **0.8-0.9**: High confidence with clear evidence +- **0.7-0.8**: Likely issue but some uncertainty +- **<0.7**: Possible issue, needs verification + +Only report findings with confidence >0.7. + +## Output Format + +Return findings in this structure: + +```json +[ + { + "id": "NEW-001", + "file": "src/auth/login.py", + "line": 45, + "end_line": 48, + "title": "SQL injection in new login query", + "description": "The new login validation query concatenates user input directly into the SQL string without sanitization.", + "category": "security", + "severity": "critical", + "confidence": 0.95, + "suggested_fix": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE email = ?', (email,))", + "fixable": true, + "source_agent": "new-code-reviewer", + "related_to_previous": null + }, + { + "id": "NEW-002", + "file": "src/utils/parser.py", + "line": 112, + "title": "Fix introduced null pointer regression", + "description": "The fix for LOGIC-003 removed a null check that was protecting against undefined input. Now input.data can be null.", + "category": "regression", + "severity": "high", + "confidence": 0.88, + "suggested_fix": "Restore null check: if (input && input.data) { ... }", + "fixable": true, + "source_agent": "new-code-reviewer", + "related_to_previous": "LOGIC-003" + } +] +``` + +## What NOT to Report + +- Issues in unchanged code (that's for initial review) +- Style preferences without functional impact +- Theoretical issues with <70% confidence +- Duplicate findings (check if similar issue exists) +- Issues already flagged by previous review + +## Review Strategy + +1. **Scan for red flags first** + - eval(), exec(), dangerouslySetInnerHTML + - Hardcoded passwords, API keys + - SQL string concatenation + - Shell command construction + +2. **Check fix correctness** + - Does the fix actually address the reported issue? + - Are all code paths covered? + - Are error cases handled? + +3. **Look for collateral damage** + - What else changed in the same files? + - Could the fix affect other functionality? + - Are there dependent changes needed? + +4. **Verify completeness** + - Are there TODOs left behind? + - Is there test coverage for the changes? + - Is documentation updated if needed? + +## Important Notes + +1. **Be focused**: Only review new changes, not the entire PR +2. **Consider context**: Understand what the fix was trying to achieve +3. **Be constructive**: Suggest fixes, not just problems +4. **Avoid nitpicking**: Focus on functional issues +5. **Link regressions**: If a fix caused a new issue, reference the original finding diff --git a/apps/backend/prompts/github/pr_followup_orchestrator.md b/apps/backend/prompts/github/pr_followup_orchestrator.md new file mode 100644 index 00000000..39851d86 --- /dev/null +++ b/apps/backend/prompts/github/pr_followup_orchestrator.md @@ -0,0 +1,141 @@ +# Parallel Follow-up Review Orchestrator + +You are the orchestrating agent for follow-up PR reviews. Your job is to analyze incremental changes since the last review and coordinate specialized agents to verify resolution of previous findings and identify new issues. + +## Your Mission + +Perform a focused, efficient follow-up review by: +1. Analyzing the scope of changes since the last review +2. Delegating to specialized agents based on what needs verification +3. Synthesizing findings into a final merge verdict + +## Available Specialist Agents + +You have access to these specialist agents via the Task tool: + +### 1. resolution-verifier +**Use for**: Verifying whether previous findings have been addressed +- Analyzes diffs to determine if issues are truly fixed +- Checks for incomplete or incorrect fixes +- Provides confidence scores for each resolution +- **Invoke when**: There are previous findings to verify + +### 2. new-code-reviewer +**Use for**: Reviewing new code added since last review +- Security issues in new code +- Logic errors and edge cases +- Code quality problems +- Regressions that may have been introduced +- **Invoke when**: There are substantial code changes (>50 lines diff) + +### 3. comment-analyzer +**Use for**: Processing contributor and AI tool feedback +- Identifies unanswered questions from contributors +- Triages AI tool comments (CodeRabbit, Cursor, Gemini, etc.) +- Flags concerns that need addressing +- **Invoke when**: There are comments or reviews since last review + +## Workflow + +### Phase 1: Analyze Scope +Evaluate the follow-up context: +- How many new commits? +- How many files changed? +- What's the diff size? +- Are there previous findings to verify? +- Are there new comments to process? + +### Phase 2: Delegate to Agents +Based on your analysis, invoke the appropriate agents: + +**Always invoke** `resolution-verifier` if there are previous findings. + +**Invoke** `new-code-reviewer` if: +- Diff is substantial (>50 lines) +- Changes touch security-sensitive areas +- New files were added +- Complex logic was modified + +**Invoke** `comment-analyzer` if: +- There are contributor comments since last review +- There are AI tool reviews to triage +- Questions remain unanswered + +### Phase 3: Synthesize Results +After agents complete: +1. Combine resolution verifications +2. Merge new findings (deduplicate if needed) +3. Incorporate comment analysis +4. Generate final verdict + +## Verdict Guidelines + +### READY_TO_MERGE +- All previous findings verified as resolved +- No new critical/high issues +- No blocking concerns from comments +- Contributor questions addressed + +### MERGE_WITH_CHANGES +- Previous findings resolved +- Only LOW severity new issues (suggestions) +- Optional polish items can be addressed post-merge + +### NEEDS_REVISION (Strict Quality Gates) +- HIGH or MEDIUM severity findings unresolved +- New HIGH or MEDIUM severity issues introduced +- Important contributor concerns unaddressed +- **Note: Both HIGH and MEDIUM block merge** (AI fixes quickly, so be strict) + +### BLOCKED +- CRITICAL findings remain unresolved +- New CRITICAL issues introduced +- Fundamental problems with the fix approach + +## Cross-Validation + +When multiple agents report on the same area: +- **Agreement boosts confidence**: If resolution-verifier and new-code-reviewer both flag an issue, increase severity +- **Conflicts need resolution**: If agents disagree, investigate and document your reasoning +- **Track consensus**: Note which findings have cross-agent validation + +## Output Format + +Provide your synthesis as a structured response matching the ParallelFollowupResponse schema: + +```json +{ + "analysis_summary": "Brief summary of what was analyzed", + "agents_invoked": ["resolution-verifier", "new-code-reviewer"], + "commits_analyzed": 5, + "files_changed": 12, + "resolution_verifications": [...], + "new_findings": [...], + "comment_analyses": [...], + "comment_findings": [...], + "agent_agreement": { + "agreed_findings": [], + "conflicting_findings": [], + "resolution_notes": null + }, + "verdict": "READY_TO_MERGE", + "verdict_reasoning": "All 3 previous findings verified as resolved..." +} +``` + +## Important Notes + +1. **Be efficient**: Follow-up reviews should be faster than initial reviews +2. **Focus on changes**: Only review what changed since last review +3. **Trust but verify**: Don't assume fixes are correct just because files changed +4. **Acknowledge progress**: Recognize genuine effort to address feedback +5. **Be specific**: Clearly state what blocks merge if verdict is not READY_TO_MERGE + +## Context You Will Receive + +- Previous review summary and findings +- New commits since last review (SHAs, messages) +- Diff of changes since last review +- Files modified since last review +- Contributor comments since last review +- AI bot comments and reviews since last review diff --git a/apps/backend/prompts/github/pr_followup_resolution_agent.md b/apps/backend/prompts/github/pr_followup_resolution_agent.md new file mode 100644 index 00000000..c0e4c38f --- /dev/null +++ b/apps/backend/prompts/github/pr_followup_resolution_agent.md @@ -0,0 +1,128 @@ +# Resolution Verification Agent + +You are a specialized agent for verifying whether previous PR review findings have been addressed. You have been spawned by the orchestrating agent to analyze diffs and determine resolution status. + +## Your Mission + +For each previous finding, determine whether it has been: +- **resolved**: The issue is fully fixed +- **partially_resolved**: Some aspects fixed, but not complete +- **unresolved**: The issue remains or wasn't addressed +- **cant_verify**: Not enough information to determine status + +## Verification Process + +For each previous finding: + +### 1. Locate the Issue +- Find the file mentioned in the finding +- Check if that file was modified in the new changes +- If file wasn't modified, the finding is likely **unresolved** + +### 2. Analyze the Fix +If the file was modified: +- Look at the specific lines mentioned +- Check if the problematic code pattern is gone +- Verify the fix actually addresses the root cause +- Watch for "cosmetic" fixes that don't solve the problem + +### 3. Check for Regressions +- Did the fix introduce new problems? +- Is the fix approach sound? +- Are there edge cases the fix misses? + +### 4. Assign Confidence +Rate your confidence (0.0-1.0): +- **>0.9**: Clear evidence of resolution/non-resolution +- **0.7-0.9**: Strong indicators but some uncertainty +- **0.5-0.7**: Mixed signals, moderate confidence +- **<0.5**: Unclear, consider marking as cant_verify + +## Resolution Criteria + +### RESOLVED +The finding is resolved when: +- The problematic code is removed or fixed +- The fix addresses the root cause (not just symptoms) +- No new issues were introduced by the fix +- Edge cases are handled appropriately + +### PARTIALLY_RESOLVED +Mark as partially resolved when: +- Main issue is fixed but related problems remain +- Fix works for common cases but misses edge cases +- Some aspects addressed but not all +- Workaround applied instead of proper fix + +### UNRESOLVED +Mark as unresolved when: +- File wasn't modified at all +- Code pattern still present +- Fix attempt doesn't address the actual issue +- Problem was misunderstood + +### CANT_VERIFY +Use when: +- Diff doesn't include enough context +- Issue requires runtime verification +- Finding references external dependencies +- Not enough information to determine + +## Evidence Requirements + +For each verification, provide: +1. **What you looked for**: The code pattern or issue from the finding +2. **What you found**: The current state in the diff +3. **Why you concluded**: Your reasoning for the status + +## Output Format + +Return verifications in this structure: + +```json +[ + { + "finding_id": "SEC-001", + "status": "resolved", + "confidence": 0.92, + "evidence": "The SQL query at line 45 now uses parameterized queries instead of string concatenation. The fix properly escapes all user inputs.", + "resolution_notes": "Changed from f-string to cursor.execute() with parameters" + }, + { + "finding_id": "QUAL-002", + "status": "partially_resolved", + "confidence": 0.75, + "evidence": "Error handling was added for the main path, but the fallback path at line 78 still lacks try-catch.", + "resolution_notes": "Main function fixed, helper function still needs work" + }, + { + "finding_id": "LOGIC-003", + "status": "unresolved", + "confidence": 0.88, + "evidence": "The off-by-one error remains. The loop still uses `<= length` instead of `< length`.", + "resolution_notes": null + } +] +``` + +## Common Pitfalls + +### False Positives (Marking resolved when not) +- Code moved but same bug exists elsewhere +- Variable renamed but logic unchanged +- Comments added but no actual fix +- Different code path has same issue + +### False Negatives (Marking unresolved when fixed) +- Fix uses different approach than expected +- Issue fixed via configuration change +- Problem resolved by removing feature entirely +- Upstream dependency update fixed it + +## Important Notes + +1. **Be thorough**: Check both the specific line AND surrounding context +2. **Consider intent**: What was the fix trying to achieve? +3. **Look for patterns**: If one instance was fixed, were all instances fixed? +4. **Document clearly**: Your evidence should be verifiable by others +5. **When uncertain**: Use lower confidence, don't guess at status diff --git a/apps/backend/prompts/github/pr_logic_agent.md b/apps/backend/prompts/github/pr_logic_agent.md new file mode 100644 index 00000000..5b81b2bd --- /dev/null +++ b/apps/backend/prompts/github/pr_logic_agent.md @@ -0,0 +1,203 @@ +# Logic and Correctness Review Agent + +You are a focused logic and correctness review agent. You have been spawned by the orchestrating agent to perform deep analysis of algorithmic correctness, edge cases, and state management. + +## Your Mission + +Verify that the code logic is correct, handles all edge cases, and doesn't introduce subtle bugs. Focus ONLY on logic and correctness issues - not style, security, or general quality. + +## Logic Focus Areas + +### 1. Algorithm Correctness +- **Wrong Algorithm**: Using inefficient or incorrect algorithm for the problem +- **Incorrect Implementation**: Algorithm logic doesn't match the intended behavior +- **Missing Steps**: Algorithm is incomplete or skips necessary operations +- **Wrong Data Structure**: Using inappropriate data structure for the operation + +### 2. Edge Cases +- **Empty Inputs**: Empty arrays, empty strings, null/undefined values +- **Boundary Conditions**: First/last elements, zero, negative numbers, max values +- **Single Element**: Arrays with one item, strings with one character +- **Large Inputs**: Integer overflow, array size limits, string length limits +- **Invalid Inputs**: Wrong types, malformed data, unexpected formats + +### 3. Off-By-One Errors +- **Loop Bounds**: `<=` vs `<`, starting at 0 vs 1 +- **Array Access**: Index out of bounds, fence post errors +- **String Operations**: Substring boundaries, character positions +- **Range Calculations**: Inclusive vs exclusive ranges + +### 4. State Management +- **Race Conditions**: Concurrent access to shared state +- **Stale State**: Using outdated values after async operations +- **State Mutation**: Unintended side effects from mutations +- **Initialization**: Using uninitialized or partially initialized state +- **Cleanup**: State not reset when it should be + +### 5. Conditional Logic +- **Inverted Conditions**: `!condition` when `condition` was intended +- **Missing Conditions**: Incomplete if/else chains +- **Wrong Operators**: `&&` vs `||`, `==` vs `===` +- **Short-Circuit Issues**: Relying on evaluation order incorrectly +- **Truthiness Bugs**: `0`, `""`, `[]` being falsy when they're valid values + +### 6. Async/Concurrent Issues +- **Missing Await**: Async function called without await +- **Promise Handling**: Unhandled rejections, missing error handling +- **Deadlocks**: Circular dependencies in async operations +- **Race Conditions**: Multiple async operations accessing same resource +- **Order Dependencies**: Operations that must run in sequence but don't + +### 7. Type Coercion & Comparisons +- **Implicit Coercion**: `"5" + 3 = "53"` vs `"5" - 3 = 2` +- **Equality Bugs**: `==` performing unexpected coercion +- **Sorting Issues**: Default string sort on numbers `[1, 10, 2]` +- **Falsy Confusion**: `0`, `""`, `null`, `undefined`, `NaN`, `false` + +## Review Guidelines + +### High Confidence Only +- Only report findings with **>80% confidence** +- Logic bugs must be demonstrable with a concrete example +- If the edge case is theoretical without practical impact, don't report it + +### Severity Classification (All block merge except LOW) +- **CRITICAL** (Blocker): Bug that will cause wrong results or crashes in production + - Example: Off-by-one causing data corruption, race condition causing lost updates + - **Blocks merge: YES** +- **HIGH** (Required): Logic error that will affect some users/cases + - Example: Missing null check, incorrect boundary condition + - **Blocks merge: YES** +- **MEDIUM** (Recommended): Edge case not handled that could cause issues + - Example: Empty array not handled, large input overflow + - **Blocks merge: YES** (AI fixes quickly, so be strict about quality) +- **LOW** (Suggestion): Minor logic improvement + - Example: Unnecessary re-computation, suboptimal algorithm + - **Blocks merge: NO** (optional polish) + +### Provide Concrete Examples +For each finding, provide: +1. A concrete input that triggers the bug +2. What the current code produces +3. What it should produce + +## Code Patterns to Flag + +### Off-By-One Errors +```javascript +// BUG: Skips last element +for (let i = 0; i < arr.length - 1; i++) { } + +// BUG: Accesses beyond array +for (let i = 0; i <= arr.length; i++) { } + +// BUG: Wrong substring bounds +str.substring(0, str.length - 1) // Missing last char +``` + +### Edge Case Failures +```javascript +// BUG: Crashes on empty array +const first = arr[0].value; // TypeError if empty + +// BUG: NaN on empty array +const avg = sum / arr.length; // Division by zero + +// BUG: Wrong result for single element +const max = Math.max(...arr.slice(1)); // Wrong if arr.length === 1 +``` + +### State & Async Bugs +```javascript +// BUG: Race condition +let count = 0; +await Promise.all(items.map(async () => { + count++; // Not atomic! +})); + +// BUG: Stale closure +for (var i = 0; i < 5; i++) { + setTimeout(() => console.log(i), 100); // All print 5 +} + +// BUG: Missing await +async function process() { + getData(); // Returns immediately, doesn't wait + useData(); // Data not ready! +} +``` + +### Conditional Logic Bugs +```javascript +// BUG: Inverted condition +if (!user.isAdmin) { + grantAccess(); // Should be if (user.isAdmin) +} + +// BUG: Wrong operator precedence +if (a || b && c) { // Evaluates as: a || (b && c) + // Probably meant: (a || b) && c +} + +// BUG: Falsy check fails for 0 +if (!value) { // Fails when value is 0 + value = defaultValue; +} +``` + +## Output Format + +Provide findings in JSON format: + +```json +[ + { + "file": "src/utils/array.ts", + "line": 23, + "title": "Off-by-one error in array iteration", + "description": "Loop uses `i < arr.length - 1` which skips the last element. For array [1, 2, 3], only processes [1, 2].", + "category": "logic", + "severity": "high", + "example": { + "input": "[1, 2, 3]", + "actual_output": "Processes [1, 2]", + "expected_output": "Processes [1, 2, 3]" + }, + "suggested_fix": "Change loop to `i < arr.length` to include last element", + "confidence": 95 + }, + { + "file": "src/services/counter.ts", + "line": 45, + "title": "Race condition in concurrent counter increment", + "description": "Multiple async operations increment `count` without synchronization. With 10 concurrent increments, final count could be less than 10.", + "category": "logic", + "severity": "critical", + "example": { + "input": "10 concurrent increments", + "actual_output": "count might be 7, 8, or 9", + "expected_output": "count should be 10" + }, + "suggested_fix": "Use atomic operations or a mutex: await mutex.runExclusive(() => count++)", + "confidence": 90 + } +] +``` + +## Important Notes + +1. **Provide Examples**: Every logic bug should have a concrete triggering input +2. **Show Impact**: Explain what goes wrong, not just that something is wrong +3. **Be Specific**: Point to exact line and explain the logical flaw +4. **Consider Context**: Some "bugs" are intentional (e.g., skipping last element on purpose) +5. **Focus on Changed Code**: Prioritize reviewing additions over existing code + +## What NOT to Report + +- Style issues (naming, formatting) +- Security issues (handled by security agent) +- Performance issues (unless it's algorithmic complexity bug) +- Code quality (duplication, complexity - handled by quality agent) +- Test files with intentionally buggy code for testing + +Focus on **logic correctness** - the code doing what it's supposed to do, handling all cases correctly. diff --git a/apps/backend/prompts/github/pr_orchestrator.md b/apps/backend/prompts/github/pr_orchestrator.md index 416b3177..0decf43a 100644 --- a/apps/backend/prompts/github/pr_orchestrator.md +++ b/apps/backend/prompts/github/pr_orchestrator.md @@ -183,12 +183,14 @@ if (!exists) { **Deduplicate** - Remove duplicates by (file, line, title) -**Generate Verdict:** +**Generate Verdict (Strict Quality Gates):** - **BLOCKED** - If any CRITICAL issues or tests failing -- **NEEDS_REVISION** - If HIGH severity issues -- **MERGE_WITH_CHANGES** - If only MEDIUM issues +- **NEEDS_REVISION** - If HIGH or MEDIUM severity issues (both block merge) +- **MERGE_WITH_CHANGES** - If only LOW severity suggestions - **READY_TO_MERGE** - If no blocking issues + tests pass + good coverage +Note: MEDIUM severity blocks merge because AI fixes quickly - be strict about quality. + --- ## Available Tools diff --git a/apps/backend/prompts/github/pr_parallel_orchestrator.md b/apps/backend/prompts/github/pr_parallel_orchestrator.md new file mode 100644 index 00000000..fbe34fb9 --- /dev/null +++ b/apps/backend/prompts/github/pr_parallel_orchestrator.md @@ -0,0 +1,146 @@ +# Parallel PR Review Orchestrator + +You are an expert PR reviewer orchestrating a comprehensive, parallel code review. Your role is to analyze the PR, delegate to specialized review agents, and synthesize their findings into a final verdict. + +## Core Principle + +**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists. + +## Available Specialist Agents + +You have access to these specialized review agents via the Task tool: + +### security-reviewer +**Description**: Security specialist for OWASP Top 10, authentication, injection, cryptographic issues, and sensitive data exposure. +**When to use**: PRs touching auth, API endpoints, user input handling, database queries, file operations, or any security-sensitive code. + +### quality-reviewer +**Description**: Code quality expert for complexity, duplication, error handling, maintainability, and pattern adherence. +**When to use**: PRs with complex logic, large functions, new patterns, or significant business logic changes. + +### logic-reviewer +**Description**: Logic and correctness specialist for algorithm verification, edge cases, state management, and race conditions. +**When to use**: PRs with algorithmic changes, data transformations, state management, concurrent operations, or bug fixes. + +### codebase-fit-reviewer +**Description**: Codebase consistency expert for naming conventions, ecosystem fit, architectural alignment, and avoiding reinvention. +**When to use**: PRs introducing new patterns, large additions, or code that might duplicate existing functionality. + +### ai-triage-reviewer +**Description**: AI comment validator for triaging comments from CodeRabbit, Gemini Code Assist, Cursor, Greptile, and other AI reviewers. +**When to use**: PRs that have existing AI review comments that need validation. + +## Your Workflow + +### Phase 1: Analysis + +Analyze the PR thoroughly: + +1. **Understand the Goal**: What does this PR claim to do? Bug fix? Feature? Refactor? +2. **Assess Scope**: How many files? What types? What areas of the codebase? +3. **Identify Risk Areas**: Security-sensitive? Complex logic? New patterns? +4. **Check for AI Comments**: Are there existing AI reviewer comments to triage? + +### Phase 2: Delegation + +Based on your analysis, invoke the appropriate specialist agents. You can invoke multiple agents in parallel by calling the Task tool multiple times in the same response. + +**Delegation Guidelines** (YOU decide, these are suggestions): + +- **Small PRs (1-5 files)**: At minimum, invoke one agent for deep analysis. Choose based on content. +- **Medium PRs (5-20 files)**: Invoke 2-3 agents covering different aspects (e.g., security + quality). +- **Large PRs (20+ files)**: Invoke 3-4 agents with focused file assignments. +- **Security-sensitive changes**: Always invoke security-reviewer. +- **Complex logic changes**: Always invoke logic-reviewer. +- **New patterns/large additions**: Always invoke codebase-fit-reviewer. +- **Existing AI comments**: Always invoke ai-triage-reviewer. + +**Example delegation**: +``` +For a PR adding a new authentication endpoint: +- Invoke security-reviewer for auth logic +- Invoke quality-reviewer for code structure +- Invoke logic-reviewer for edge cases in auth flow +``` + +### Phase 3: Synthesis + +After receiving agent results, synthesize findings: + +1. **Aggregate**: Collect all findings from all agents +2. **Cross-validate**: + - If multiple agents report the same issue → boost confidence + - If agents conflict → use your judgment to resolve +3. **Deduplicate**: Remove overlapping findings (same file + line + issue type) +4. **Filter**: Only include findings with confidence ≥80% +5. **Generate Verdict**: Based on severity of remaining findings + +## Output Format + +After synthesis, output your final review in this JSON format: + +```json +{ + "analysis_summary": "Brief description of what you analyzed and why you chose those agents", + "agents_invoked": ["security-reviewer", "quality-reviewer"], + "findings": [ + { + "id": "finding-1", + "file": "src/auth/login.ts", + "line": 45, + "end_line": 52, + "title": "SQL injection vulnerability in user lookup", + "description": "User input directly interpolated into SQL query", + "category": "security", + "severity": "critical", + "confidence": 0.95, + "suggested_fix": "Use parameterized queries", + "fixable": true, + "source_agents": ["security-reviewer"], + "cross_validated": false + } + ], + "agent_agreement": { + "agreed_findings": ["finding-1", "finding-3"], + "conflicting_findings": [], + "resolution_notes": "" + }, + "verdict": "NEEDS_REVISION", + "verdict_reasoning": "Critical SQL injection vulnerability must be fixed before merge" +} +``` + +## Verdict Types (Strict Quality Gates) + +We use strict quality gates because AI can fix issues quickly. Only LOW severity findings are optional. + +- **READY_TO_MERGE**: No blocking issues found - can merge +- **MERGE_WITH_CHANGES**: Only LOW (Suggestion) severity findings - can merge but consider addressing +- **NEEDS_REVISION**: HIGH or MEDIUM severity findings that must be fixed before merge +- **BLOCKED**: CRITICAL severity issues or failing tests - must be fixed before merge + +**Severity → Verdict Mapping:** +- CRITICAL → BLOCKED (must fix) +- HIGH → NEEDS_REVISION (required fix) +- MEDIUM → NEEDS_REVISION (recommended, improves quality - also blocks merge) +- LOW → MERGE_WITH_CHANGES (optional suggestions) + +## Key Principles + +1. **YOU Decide**: No hardcoded rules - you analyze and choose agents based on content +2. **Parallel Execution**: Invoke multiple agents in the same turn for speed +3. **Thoroughness**: Every PR deserves analysis - never skip because it "looks simple" +4. **Cross-Validation**: Multiple agents agreeing increases confidence +5. **High Confidence**: Only report findings with ≥80% confidence +6. **Actionable**: Every finding must have a specific, actionable fix +7. **Project Agnostic**: Works for any project type - backend, frontend, fullstack, any language + +## Remember + +You are the orchestrator. The specialist agents provide deep expertise, but YOU make the final decisions about: +- Which agents to invoke +- How to resolve conflicts +- What findings to include +- What verdict to give + +Quality over speed. A missed bug in production is far worse than spending extra time on review. diff --git a/apps/backend/prompts/github/pr_quality_agent.md b/apps/backend/prompts/github/pr_quality_agent.md index 294a14b8..f3007f1f 100644 --- a/apps/backend/prompts/github/pr_quality_agent.md +++ b/apps/backend/prompts/github/pr_quality_agent.md @@ -62,15 +62,19 @@ Perform a thorough code quality review of the provided code changes. Focus on ma - If it's subjective or debatable, don't report it - Focus on objective quality issues -### Severity Classification -- **CRITICAL**: Bug that will cause failures in production +### Severity Classification (All block merge except LOW) +- **CRITICAL** (Blocker): Bug that will cause failures in production - Example: Unhandled promise rejection, memory leak -- **HIGH**: Significant quality issue affecting maintainability + - **Blocks merge: YES** +- **HIGH** (Required): Significant quality issue affecting maintainability - Example: 200-line function, duplicated business logic across 5 files -- **MEDIUM**: Quality concern worth addressing + - **Blocks merge: YES** +- **MEDIUM** (Recommended): Quality concern that improves code quality - Example: Missing error handling, magic numbers -- **LOW**: Minor improvement suggestion + - **Blocks merge: YES** (AI fixes quickly, so be strict about quality) +- **LOW** (Suggestion): Minor improvement suggestion - Example: Variable naming, minor refactoring opportunity + - **Blocks merge: NO** (optional polish) ### Contextual Analysis - Consider project conventions (don't enforce personal preferences) diff --git a/apps/backend/prompts/github/pr_reviewer.md b/apps/backend/prompts/github/pr_reviewer.md index a69cf706..72a8b5da 100644 --- a/apps/backend/prompts/github/pr_reviewer.md +++ b/apps/backend/prompts/github/pr_reviewer.md @@ -264,11 +264,11 @@ Return a JSON array with this structure: ### Required Fields - **id**: Unique identifier (e.g., "finding-1", "finding-2") -- **severity**: `critical` | `high` | `medium` | `low` - - **critical**: Must fix before merge (security vulnerabilities, data loss risks) - - **high**: Should fix before merge (significant bugs, major quality issues) - - **medium**: Recommended to fix (code quality, maintainability concerns) - - **low**: Suggestions for improvement (minor enhancements) +- **severity**: `critical` | `high` | `medium` | `low` (Strict Quality Gates - all block merge except LOW) + - **critical** (Blocker): Must fix before merge (security vulnerabilities, data loss risks) - **Blocks merge: YES** + - **high** (Required): Should fix before merge (significant bugs, major quality issues) - **Blocks merge: YES** + - **medium** (Recommended): Improve code quality (maintainability concerns) - **Blocks merge: YES** (AI fixes quickly) + - **low** (Suggestion): Suggestions for improvement (minor enhancements) - **Blocks merge: NO** - **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance` - **confidence**: Float 0.0-1.0 representing your confidence this is a genuine issue (must be ≥0.80) - **title**: Short, specific summary (max 80 chars) diff --git a/apps/backend/prompts/github/pr_security_agent.md b/apps/backend/prompts/github/pr_security_agent.md index 7a72dfbc..e2c3ae36 100644 --- a/apps/backend/prompts/github/pr_security_agent.md +++ b/apps/backend/prompts/github/pr_security_agent.md @@ -57,15 +57,19 @@ Perform a thorough security review of the provided code changes, focusing ONLY o - If you're unsure, don't report it - Prefer false negatives over false positives -### Severity Classification -- **CRITICAL**: Exploitable vulnerability leading to data breach, RCE, or system compromise +### Severity Classification (All block merge except LOW) +- **CRITICAL** (Blocker): Exploitable vulnerability leading to data breach, RCE, or system compromise - Example: SQL injection, hardcoded admin password -- **HIGH**: Serious security flaw that could be exploited + - **Blocks merge: YES** +- **HIGH** (Required): Serious security flaw that could be exploited - Example: Missing authentication check, XSS vulnerability -- **MEDIUM**: Security weakness that increases risk + - **Blocks merge: YES** +- **MEDIUM** (Recommended): Security weakness that increases risk - Example: Weak password requirements, missing security headers -- **LOW**: Best practice violation, minimal risk + - **Blocks merge: YES** (AI fixes quickly, so be strict about security) +- **LOW** (Suggestion): Best practice violation, minimal risk - Example: Using MD5 for non-security checksums + - **Blocks merge: NO** (optional polish) ### Contextual Analysis - Consider the application type (public API vs internal tool) diff --git a/apps/backend/runners/github/bot_detection.py b/apps/backend/runners/github/bot_detection.py index 6821ab4c..370ab272 100644 --- a/apps/backend/runners/github/bot_detection.py +++ b/apps/backend/runners/github/bot_detection.py @@ -34,6 +34,11 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path +try: + from .file_lock import FileLock, atomic_write +except (ImportError, ValueError, SystemError): + from file_lock import FileLock, atomic_write + @dataclass class BotDetectionState: @@ -61,12 +66,14 @@ class BotDetectionState: ) def save(self, state_dir: Path) -> None: - """Save state to disk.""" + """Save state to disk with file locking for concurrent safety.""" state_dir.mkdir(parents=True, exist_ok=True) state_file = state_dir / "bot_detection_state.json" - with open(state_file, "w") as f: - json.dump(self.to_dict(), f, indent=2) + # Use file locking to prevent concurrent write corruption + with FileLock(state_file, timeout=5.0, exclusive=True): + with atomic_write(state_file) as f: + json.dump(self.to_dict(), f, indent=2) @classmethod def load(cls, state_dir: Path) -> BotDetectionState: @@ -311,8 +318,9 @@ class BotDetector: return True, reason # Check 2: Is the latest commit by the bot? + # Note: GitHub API returns commits oldest-first, so commits[-1] is the latest if commits and not self.review_own_prs: - latest_commit = commits[0] if commits else None + latest_commit = commits[-1] if commits else None if latest_commit and self.is_bot_commit(latest_commit): reason = "Latest commit authored by bot (likely an auto-fix)" print(f"[BotDetector] SKIP PR #{pr_number}: {reason}") @@ -403,3 +411,44 @@ class BotDetector: "total_reviews_performed": total_reviews, "cooling_off_minutes": self.COOLING_OFF_MINUTES, } + + def cleanup_stale_prs(self, max_age_days: int = 30) -> int: + """ + Remove tracking state for PRs that haven't been reviewed recently. + + This prevents unbounded growth of the state file by cleaning up + entries for PRs that are likely closed/merged. + + Args: + max_age_days: Remove PRs not reviewed in this many days (default: 30) + + Returns: + Number of PRs cleaned up + """ + cutoff = datetime.now() - timedelta(days=max_age_days) + prs_to_remove: list[str] = [] + + for pr_key, last_review_str in self.state.last_review_times.items(): + try: + last_review = datetime.fromisoformat(last_review_str) + if last_review < cutoff: + prs_to_remove.append(pr_key) + except (ValueError, TypeError): + # Invalid timestamp - mark for removal + prs_to_remove.append(pr_key) + + # Remove stale PRs + for pr_key in prs_to_remove: + if pr_key in self.state.reviewed_commits: + del self.state.reviewed_commits[pr_key] + if pr_key in self.state.last_review_times: + del self.state.last_review_times[pr_key] + + if prs_to_remove: + self.state.save(self.state_dir) + print( + f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs " + f"(older than {max_age_days} days)" + ) + + return len(prs_to_remove) diff --git a/apps/backend/runners/github/context_gatherer.py b/apps/backend/runners/github/context_gatherer.py index b0cc7bfc..0b55feff 100644 --- a/apps/backend/runners/github/context_gatherer.py +++ b/apps/backend/runners/github/context_gatherer.py @@ -121,6 +121,11 @@ AI_BOT_PATTERNS: dict[str, str] = { "codium-ai[bot]": "Qodo", "codiumai-agent": "Qodo", "qodo-merge-bot": "Qodo", + # === Google AI === + "gemini-code-assist": "Gemini Code Assist", + "gemini-code-assist[bot]": "Gemini Code Assist", + "google-code-assist": "Gemini Code Assist", + "google-code-assist[bot]": "Gemini Code Assist", # === AI Coding Assistants === "copilot": "GitHub Copilot", "copilot[bot]": "GitHub Copilot", @@ -352,7 +357,7 @@ class PRContextGatherer: if proc.returncode == 0: print( - f"[Context] Fetched PR refs: {head_sha[:8]}...{base_sha[:8]}", + f"[Context] Fetched PR refs: base={base_sha[:8]} → head={head_sha[:8]}", flush=True, ) return True @@ -870,8 +875,9 @@ class PRContextGatherer: # Start from the directory containing the source file base_dir = source_path.parent - # Resolve relative path - resolved = (base_dir / import_path).resolve() + # Resolve relative path - MUST prepend project_dir to resolve correctly + # when CWD is different from project root (e.g., running from apps/backend/) + resolved = (self.project_dir / base_dir / import_path).resolve() # Try common extensions if no extension provided if not resolved.suffix: @@ -1035,6 +1041,7 @@ class FollowupContextGatherer: previous_review=self.previous_review, previous_commit_sha=previous_sha, current_commit_sha=current_sha, + error=f"Failed to compare commits: {e}", ) # Extract data from comparison diff --git a/apps/backend/runners/github/models.py b/apps/backend/runners/github/models.py index 280ffd13..ed9f83c3 100644 --- a/apps/backend/runners/github/models.py +++ b/apps/backend/runners/github/models.py @@ -375,6 +375,13 @@ class PRReviewResult: default_factory=list ) # New issues in recent commits + # Posted findings tracking (for frontend state sync) + has_posted_findings: bool = False # True if any findings have been posted to GitHub + posted_finding_ids: list[str] = field( + default_factory=list + ) # IDs of posted findings + posted_at: str | None = None # Timestamp when findings were posted + def to_dict(self) -> dict: return { "pr_number": self.pr_number, @@ -401,6 +408,10 @@ class PRReviewResult: "resolved_findings": self.resolved_findings, "unresolved_findings": self.unresolved_findings, "new_findings_since_last_review": self.new_findings_since_last_review, + # Posted findings tracking + "has_posted_findings": self.has_posted_findings, + "posted_finding_ids": self.posted_finding_ids, + "posted_at": self.posted_at, } @classmethod @@ -443,6 +454,10 @@ class PRReviewResult: new_findings_since_last_review=data.get( "new_findings_since_last_review", [] ), + # Posted findings tracking + has_posted_findings=data.get("has_posted_findings", False), + posted_finding_ids=data.get("posted_finding_ids", []), + posted_at=data.get("posted_at"), ) async def save(self, github_dir: Path) -> None: @@ -529,6 +544,9 @@ class FollowupReviewContext: # These are different from comments - they're full review submissions with body text pr_reviews_since_review: list[dict] = field(default_factory=list) + # Error flag - if set, context gathering failed and data may be incomplete + error: str | None = None + @dataclass class TriageResult: @@ -773,7 +791,12 @@ class GitHubRunnerConfig: auto_post_reviews: bool = False allow_fix_commits: bool = True review_own_prs: bool = False # Whether bot can review its own PRs - use_orchestrator_review: bool = True # Use new Opus 4.5 orchestrating agent + use_orchestrator_review: bool = ( + True # DEPRECATED: No longer used, kept for config compatibility + ) + use_parallel_orchestrator: bool = ( + True # Use SDK subagent parallel orchestrator (default) + ) # Model settings model: str = "claude-sonnet-4-20250514" diff --git a/apps/backend/runners/github/orchestrator.py b/apps/backend/runners/github/orchestrator.py index a06c8bd1..1d132dc2 100644 --- a/apps/backend/runners/github/orchestrator.py +++ b/apps/backend/runners/github/orchestrator.py @@ -277,12 +277,16 @@ class GitHubOrchestrator: # PR REVIEW WORKFLOW # ========================================================================= - async def review_pr(self, pr_number: int) -> PRReviewResult: + async def review_pr( + self, pr_number: int, force_review: bool = False + ) -> PRReviewResult: """ Perform AI-powered review of a pull request. Args: pr_number: The PR number to review + force_review: If True, bypass the "already reviewed" check and force a new review. + Useful for re-validating a PR or testing the review system. Returns: PRReviewResult with findings and overall assessment @@ -321,11 +325,34 @@ class GitHubOrchestrator: commits=pr_context.commits, ) + # Allow forcing a review to bypass "already reviewed" check + if should_skip and force_review and "Already reviewed" in skip_reason: + print( + f"[BOT DETECTION] Force review requested - bypassing: {skip_reason}", + flush=True, + ) + should_skip = False + if should_skip: print( f"[BOT DETECTION] Skipping PR #{pr_number}: {skip_reason}", flush=True, ) + + # If skipping because "Already reviewed", return the existing review + # instead of creating a new empty "skipped" result + if "Already reviewed" in skip_reason: + existing_review = PRReviewResult.load(self.github_dir, pr_number) + if existing_review: + print( + "[BOT DETECTION] Returning existing review (no new commits)", + flush=True, + ) + # Don't overwrite - return the existing review as-is + # The frontend will see "no new commits" via the newCommitsCheck + return existing_review + + # For other skip reasons (bot-authored, cooling off), create a skip result result = PRReviewResult( pr_number=pr_number, repo=self.config.repo, @@ -535,6 +562,30 @@ class GitHubOrchestrator: ) followup_context = await gatherer.gather() + # Check if context gathering failed + if followup_context.error: + print( + f"[Followup] Context gathering failed: {followup_context.error}", + flush=True, + ) + # Return an error result instead of silently returning incomplete data + result = PRReviewResult( + pr_number=pr_number, + repo=self.config.repo, + success=False, + findings=[], + summary=f"Follow-up review failed: {followup_context.error}", + overall_status="comment", + verdict=MergeVerdict.NEEDS_REVISION, + verdict_reasoning=f"Context gathering failed: {followup_context.error}", + error=followup_context.error, + reviewed_commit_sha=followup_context.current_commit_sha + or previous_review.reviewed_commit_sha, + is_followup_review=True, + ) + await result.save(self.github_dir) + return result + # Check if there are new commits if not followup_context.commits_since_review: print( @@ -566,20 +617,49 @@ class GitHubOrchestrator: pr_number=pr_number, ) - # Run follow-up review - reviewer = FollowupReviewer( - project_dir=self.project_dir, - github_dir=self.github_dir, - config=self.config, - progress_callback=lambda p: self._report_progress( - p.get("phase", "analyzing"), - p.get("progress", 50), - p.get("message", "Reviewing..."), - pr_number=pr_number, - ), - ) + # Use parallel orchestrator for follow-up if enabled + if self.config.use_parallel_orchestrator: + print( + "[AI] Using parallel orchestrator for follow-up review (SDK subagents)...", + flush=True, + ) + try: + from .services.parallel_followup_reviewer import ( + ParallelFollowupReviewer, + ) + except (ImportError, ValueError, SystemError): + from services.parallel_followup_reviewer import ( + ParallelFollowupReviewer, + ) - result = await reviewer.review_followup(followup_context) + reviewer = ParallelFollowupReviewer( + project_dir=self.project_dir, + github_dir=self.github_dir, + config=self.config, + progress_callback=lambda p: self._report_progress( + p.phase if hasattr(p, "phase") else p.get("phase", "analyzing"), + p.progress if hasattr(p, "progress") else p.get("progress", 50), + p.message + if hasattr(p, "message") + else p.get("message", "Reviewing..."), + pr_number=pr_number, + ), + ) + result = await reviewer.review(followup_context) + else: + # Fall back to sequential follow-up reviewer + reviewer = FollowupReviewer( + project_dir=self.project_dir, + github_dir=self.github_dir, + config=self.config, + progress_callback=lambda p: self._report_progress( + p.get("phase", "analyzing"), + p.get("progress", 50), + p.get("message", "Reviewing..."), + pr_number=pr_number, + ), + ) + result = await reviewer.review_followup(followup_context) # Save result await result.save(self.github_dir) @@ -621,6 +701,8 @@ class GitHubOrchestrator: # Count by severity critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL] high = [f for f in findings if f.severity == ReviewSeverity.HIGH] + medium = [f for f in findings if f.severity == ReviewSeverity.MEDIUM] + low = [f for f in findings if f.severity == ReviewSeverity.LOW] # NEW: Verification failures are ALWAYS blockers (even if not critical severity) verification_failures = [ @@ -709,9 +791,17 @@ class GitHubOrchestrator: else: verdict = MergeVerdict.NEEDS_REVISION reasoning = f"{len(blockers)} issues must be addressed" - elif high: + elif high or medium: + # High and Medium severity findings block merge + verdict = MergeVerdict.NEEDS_REVISION + total = len(high) + len(medium) + reasoning = f"{total} issue(s) must be addressed ({len(high)} required, {len(medium)} recommended)" + if low: + reasoning += f", {len(low)} suggestions" + elif low: + # Only Low severity suggestions - can merge but consider addressing verdict = MergeVerdict.MERGE_WITH_CHANGES - reasoning = f"{len(high)} high-priority issues to address" + reasoning = f"{len(low)} suggestion(s) to consider" else: verdict = MergeVerdict.READY_TO_MERGE reasoning = "No blocking issues found" diff --git a/apps/backend/runners/github/runner.py b/apps/backend/runners/github/runner.py index 02af642e..669030e4 100644 --- a/apps/backend/runners/github/runner.py +++ b/apps/backend/runners/github/runner.py @@ -208,7 +208,9 @@ async def cmd_review_pr(args) -> int: f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True ) - result = await orchestrator.review_pr(args.pr_number) + # Pass force_review flag if --force was specified + force_review = getattr(args, "force", False) + result = await orchestrator.review_pr(args.pr_number, force_review=force_review) if debug: print(f"[DEBUG] review_pr returned, success={result.success}", flush=True) @@ -681,6 +683,11 @@ def main(): action="store_true", help="Automatically post review to GitHub", ) + review_parser.add_argument( + "--force", + action="store_true", + help="Force a new review even if commit was already reviewed", + ) # followup-review-pr command followup_parser = subparsers.add_parser( diff --git a/apps/backend/runners/github/services/category_utils.py b/apps/backend/runners/github/services/category_utils.py new file mode 100644 index 00000000..9c1d7d23 --- /dev/null +++ b/apps/backend/runners/github/services/category_utils.py @@ -0,0 +1,75 @@ +""" +Category Mapping Utilities +=========================== + +Shared utilities for mapping AI-generated category names to valid ReviewCategory enum values. + +This module provides a centralized category mapping system used across all PR reviewers +(orchestrator, follow-up, parallel) to ensure consistent category normalization. +""" + +from __future__ import annotations + +try: + from ..models import ReviewCategory +except (ImportError, ValueError, SystemError): + from models import ReviewCategory + + +# Map AI-generated category names to valid ReviewCategory enum values +CATEGORY_MAPPING: dict[str, ReviewCategory] = { + # Direct matches (already valid ReviewCategory values) + "security": ReviewCategory.SECURITY, + "quality": ReviewCategory.QUALITY, + "style": ReviewCategory.STYLE, + "test": ReviewCategory.TEST, + "docs": ReviewCategory.DOCS, + "pattern": ReviewCategory.PATTERN, + "performance": ReviewCategory.PERFORMANCE, + "redundancy": ReviewCategory.REDUNDANCY, + "verification_failed": ReviewCategory.VERIFICATION_FAILED, + # AI-generated alternatives that need mapping + "logic": ReviewCategory.QUALITY, # Logic errors → quality + "codebase_fit": ReviewCategory.PATTERN, # Codebase fit → pattern adherence + "correctness": ReviewCategory.QUALITY, # Code correctness → quality + "consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence + "testing": ReviewCategory.TEST, # Testing → test + "documentation": ReviewCategory.DOCS, # Documentation → docs + "bug": ReviewCategory.QUALITY, # Bug → quality + "error_handling": ReviewCategory.QUALITY, # Error handling → quality + "maintainability": ReviewCategory.QUALITY, # Maintainability → quality + "readability": ReviewCategory.STYLE, # Readability → style + "best_practices": ReviewCategory.PATTERN, # Best practices → pattern (hyphen normalized to underscore) + "architecture": ReviewCategory.PATTERN, # Architecture → pattern + "complexity": ReviewCategory.QUALITY, # Complexity → quality + "dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy + "unused": ReviewCategory.REDUNDANCY, # Unused code → redundancy + # Follow-up specific mappings + "regression": ReviewCategory.QUALITY, # Regression → quality + "incomplete_fix": ReviewCategory.QUALITY, # Incomplete fix → quality +} + + +def map_category(raw_category: str) -> ReviewCategory: + """ + Map an AI-generated category string to a valid ReviewCategory enum. + + Args: + raw_category: Raw category string from AI (e.g., "best-practices", "logic", "security") + + Returns: + ReviewCategory: Normalized category enum value. Defaults to QUALITY if unknown. + + Examples: + >>> map_category("security") + ReviewCategory.SECURITY + >>> map_category("best-practices") + ReviewCategory.PATTERN + >>> map_category("unknown-category") + ReviewCategory.QUALITY + """ + # Normalize: lowercase, strip whitespace, replace hyphens with underscores + normalized = raw_category.lower().strip().replace("-", "_") + + # Look up in mapping, default to QUALITY for unknown categories + return CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY) diff --git a/apps/backend/runners/github/services/followup_reviewer.py b/apps/backend/runners/github/services/followup_reviewer.py index b8324cf3..889197f9 100644 --- a/apps/backend/runners/github/services/followup_reviewer.py +++ b/apps/backend/runners/github/services/followup_reviewer.py @@ -33,6 +33,7 @@ try: ReviewCategory, ReviewSeverity, ) + from .category_utils import map_category from .prompt_manager import PromptManager from .pydantic_models import FollowupReviewResponse except (ImportError, ValueError, SystemError): @@ -43,41 +44,12 @@ except (ImportError, ValueError, SystemError): ReviewCategory, ReviewSeverity, ) + from services.category_utils import map_category from services.prompt_manager import PromptManager from services.pydantic_models import FollowupReviewResponse logger = logging.getLogger(__name__) -# Category mapping for AI responses -_CATEGORY_MAPPING = { - # Direct matches (already valid) - "security": ReviewCategory.SECURITY, - "quality": ReviewCategory.QUALITY, - "style": ReviewCategory.STYLE, - "test": ReviewCategory.TEST, - "docs": ReviewCategory.DOCS, - "pattern": ReviewCategory.PATTERN, - "performance": ReviewCategory.PERFORMANCE, - "verification_failed": ReviewCategory.VERIFICATION_FAILED, - "redundancy": ReviewCategory.REDUNDANCY, - # AI-generated alternatives that need mapping - "correctness": ReviewCategory.QUALITY, # Logic/code correctness → quality - "consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence - "testing": ReviewCategory.TEST, # Testing → test - "documentation": ReviewCategory.DOCS, # Documentation → docs - "bug": ReviewCategory.QUALITY, # Bug → quality - "logic": ReviewCategory.QUALITY, # Logic error → quality - "error_handling": ReviewCategory.QUALITY, # Error handling → quality - "maintainability": ReviewCategory.QUALITY, # Maintainability → quality - "readability": ReviewCategory.STYLE, # Readability → style - "best_practices": ReviewCategory.PATTERN, # Best practices → pattern - "best-practices": ReviewCategory.PATTERN, # With hyphen - "architecture": ReviewCategory.PATTERN, # Architecture → pattern - "complexity": ReviewCategory.QUALITY, # Complexity → quality - "dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy - "unused": ReviewCategory.REDUNDANCY, # Unused → redundancy -} - # Severity mapping for AI responses _SEVERITY_MAPPING = { "critical": ReviewSeverity.CRITICAL, @@ -305,9 +277,9 @@ class FollowupReviewer: resolved.append(finding) else: # File was modified but the specific line wasn't clearly changed - # Consider it potentially resolved (benefit of the doubt) - # Could be more sophisticated with AST analysis - resolved.append(finding) + # Mark as unresolved - the contributor needs to address the actual issue + # "Benefit of the doubt" was wrong - if the line wasn't changed, the issue persists + unresolved.append(finding) return resolved, unresolved @@ -470,11 +442,20 @@ class FollowupReviewer: high_unresolved = sum( 1 for f in unresolved_findings if f.severity == ReviewSeverity.HIGH ) + medium_unresolved = sum( + 1 for f in unresolved_findings if f.severity == ReviewSeverity.MEDIUM + ) + low_unresolved = sum( + 1 for f in unresolved_findings if f.severity == ReviewSeverity.LOW + ) critical_new = sum( 1 for f in new_findings if f.severity == ReviewSeverity.CRITICAL ) high_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.HIGH) + medium_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.MEDIUM) + low_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.LOW) + # Critical and High are always blockers for f in unresolved_findings: if f.severity in [ReviewSeverity.CRITICAL, ReviewSeverity.HIGH]: blockers.append(f"Unresolved: {f.title} ({f.file}:{f.line})") @@ -490,17 +471,25 @@ class FollowupReviewer: f"Still blocked by {critical_unresolved + critical_new} critical issues " f"({critical_unresolved} unresolved, {critical_new} new)" ) - elif high_unresolved > 0 or high_new > 0: + elif ( + high_unresolved > 0 + or high_new > 0 + or medium_unresolved > 0 + or medium_new > 0 + ): + # High and Medium severity findings block merge verdict = MergeVerdict.NEEDS_REVISION + total_blocking = high_unresolved + high_new + medium_unresolved + medium_new reasoning = ( - f"{high_unresolved + high_new} high-priority issues " - f"({high_unresolved} unresolved, {high_new} new)" + f"{total_blocking} issue(s) must be addressed " + f"({high_unresolved + medium_unresolved} unresolved, {high_new + medium_new} new)" ) - elif len(unresolved_findings) > 0 or len(new_findings) > 0: + elif low_unresolved > 0 or low_new > 0: + # Only Low severity suggestions remaining - can merge but consider addressing verdict = MergeVerdict.MERGE_WITH_CHANGES reasoning = ( f"{resolved_count} issues resolved. " - f"{len(unresolved_findings)} remaining, {len(new_findings)} new minor issues." + f"{low_unresolved + low_new} suggestion(s) to consider." ) else: verdict = MergeVerdict.READY_TO_MERGE @@ -650,12 +639,12 @@ class FollowupReviewer: ### AI BOT COMMENTS SINCE LAST REVIEW: {ai_comments_text if ai_comments_text else "No AI bot comments."} -### PR REVIEWS SINCE LAST REVIEW (Cursor, CodeRabbit, etc.): +### PR REVIEWS SINCE LAST REVIEW (CodeRabbit, Gemini Code Assist, Cursor, etc.): {pr_reviews_text if pr_reviews_text else "No PR reviews since last review."} --- -**IMPORTANT**: Pay special attention to the PR REVIEWS section above. These are formal code reviews from AI tools like Cursor, CodeRabbit, Greptile, etc. that may have identified issues in the recent changes. You should: +**IMPORTANT**: Pay special attention to the PR REVIEWS section above. These are formal code reviews from AI tools like CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc. that may have identified issues in the recent changes. You should: 1. Consider their findings when evaluating the code 2. Create new findings for valid issues they identified that haven't been addressed 3. Note if the recent commits addressed concerns raised in these reviews @@ -777,7 +766,7 @@ Analyze this follow-up review context and provide your structured response. PRReviewFinding( id=f.id, severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.MEDIUM), - category=_CATEGORY_MAPPING.get(f.category, ReviewCategory.QUALITY), + category=map_category(f.category), title=f.title, description=f.description, file=f.file, @@ -794,7 +783,7 @@ Analyze this follow-up review context and provide your structured response. PRReviewFinding( id=f.id, severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.LOW), - category=_CATEGORY_MAPPING.get(f.category, ReviewCategory.QUALITY), + category=map_category(f.category), title=f.title, description=f.description, file=f.file, diff --git a/apps/backend/runners/github/services/orchestrator_reviewer.py b/apps/backend/runners/github/services/orchestrator_reviewer.py deleted file mode 100644 index ce64bbd0..00000000 --- a/apps/backend/runners/github/services/orchestrator_reviewer.py +++ /dev/null @@ -1,1158 +0,0 @@ -""" -Orchestrating PR Reviewer -========================== - -Strategic PR review system using a single Opus 4.5 orchestrating agent -that makes human-like decisions about where to focus review effort. - -Replaces the fixed multi-pass system with adaptive, risk-based review. -""" - -from __future__ import annotations - -import json -import logging -import os -from pathlib import Path -from typing import Any - -# Check if debug mode is enabled (via DEBUG=true env var) -DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes") - -try: - from ...core.client import create_client - from ...phase_config import get_thinking_budget - from ..context_gatherer import PRContext - from ..models import ( - GitHubRunnerConfig, - MergeVerdict, - PRReviewFinding, - PRReviewResult, - ReviewCategory, - ReviewSeverity, - ) - from .pydantic_models import OrchestratorReviewResponse - from .review_tools import ( - check_coverage, - get_file_content, - run_tests, - spawn_deep_analysis, - spawn_quality_review, - spawn_security_review, - verify_path_exists, - ) -except (ImportError, ValueError, SystemError): - from context_gatherer import PRContext - from core.client import create_client - from models import ( - GitHubRunnerConfig, - MergeVerdict, - PRReviewFinding, - PRReviewResult, - ReviewCategory, - ReviewSeverity, - ) - from phase_config import get_thinking_budget - from services.pydantic_models import OrchestratorReviewResponse - from services.review_tools import ( - check_coverage, - get_file_content, - run_tests, - spawn_deep_analysis, - spawn_quality_review, - spawn_security_review, - verify_path_exists, - ) - -logger = logging.getLogger(__name__) - - -# Map AI-generated category names to valid ReviewCategory enum values -# The AI sometimes generates categories that aren't in our enum -_CATEGORY_MAPPING = { - # Direct matches (already valid) - "security": ReviewCategory.SECURITY, - "quality": ReviewCategory.QUALITY, - "style": ReviewCategory.STYLE, - "test": ReviewCategory.TEST, - "docs": ReviewCategory.DOCS, - "pattern": ReviewCategory.PATTERN, - "performance": ReviewCategory.PERFORMANCE, - "verification_failed": ReviewCategory.VERIFICATION_FAILED, - "redundancy": ReviewCategory.REDUNDANCY, - # AI-generated alternatives that need mapping - "correctness": ReviewCategory.QUALITY, # Logic/code correctness → quality - "consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence - "testing": ReviewCategory.TEST, # Testing → test - "documentation": ReviewCategory.DOCS, # Documentation → docs - "bug": ReviewCategory.QUALITY, # Bug → quality - "logic": ReviewCategory.QUALITY, # Logic error → quality - "error_handling": ReviewCategory.QUALITY, # Error handling → quality - "maintainability": ReviewCategory.QUALITY, # Maintainability → quality - "readability": ReviewCategory.STYLE, # Readability → style - "best_practices": ReviewCategory.PATTERN, # Best practices → pattern - "best-practices": ReviewCategory.PATTERN, # With hyphen - "architecture": ReviewCategory.PATTERN, # Architecture → pattern - "complexity": ReviewCategory.QUALITY, # Complexity → quality - "dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy - "unused": ReviewCategory.REDUNDANCY, # Unused → redundancy -} - - -def _map_category(category_str: str) -> ReviewCategory: - """ - Map an AI-generated category string to a valid ReviewCategory enum. - - Falls back to QUALITY if the category is unknown. - """ - normalized = category_str.lower().strip().replace("-", "_") - return _CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY) - - -class OrchestratorReviewer: - """ - Strategic PR reviewer using Opus 4.5 for orchestration. - - Makes human-like decisions about: - - Which files are high-risk and need deep review - - When to spawn focused subagents vs quick scan - - Whether to run tests/coverage checks - - Final verdict based on aggregated findings - """ - - def __init__( - self, - project_dir: Path, - github_dir: Path, - config: GitHubRunnerConfig, - progress_callback=None, - ): - self.project_dir = Path(project_dir) - self.github_dir = Path(github_dir) - self.config = config - self.progress_callback = progress_callback - - # Token usage tracking - self.total_tokens = 0 - self.MAX_TOTAL_BUDGET = 150_000 - - def _report_progress(self, phase: str, progress: int, message: str, **kwargs): - """Report progress if callback is set.""" - if self.progress_callback: - import sys - - if "orchestrator" in sys.modules: - ProgressCallback = sys.modules["orchestrator"].ProgressCallback - else: - try: - from ..orchestrator import ProgressCallback - except ImportError: - from orchestrator import ProgressCallback - - self.progress_callback( - ProgressCallback( - phase=phase, progress=progress, message=message, **kwargs - ) - ) - - async def review(self, context: PRContext) -> PRReviewResult: - """ - Main review entry point. - - Args: - context: Full PR context with all files and patches - - Returns: - PRReviewResult with findings and verdict - """ - logger.info( - f"[Orchestrator] Starting strategic review for PR #{context.pr_number}" - ) - - try: - self._report_progress( - "orchestrating", - 20, - "Orchestrator analyzing PR structure...", - pr_number=context.pr_number, - ) - - # Build orchestrator prompt with tool definitions - prompt = self._build_orchestrator_prompt(context) - - # Create client with user-configured model and thinking level - project_root = ( - self.project_dir.parent.parent - if self.project_dir.name == "backend" - else self.project_dir - ) - - # Use model and thinking level from config (user settings) - model = self.config.model or "claude-sonnet-4-5-20250929" - thinking_level = self.config.thinking_level or "medium" - thinking_budget = get_thinking_budget(thinking_level) - - logger.info( - f"[Orchestrator] Using model={model}, thinking_level={thinking_level}, " - f"thinking_budget={thinking_budget}" - ) - - client = create_client( - project_dir=project_root, - spec_dir=self.github_dir, - model=model, - agent_type="pr_reviewer", # Read-only - no bash, no edits - max_thinking_tokens=thinking_budget, - output_format={ - "type": "json_schema", - "schema": OrchestratorReviewResponse.model_json_schema(), - }, - ) - - self._report_progress( - "orchestrating", - 30, - "Orchestrator making strategic decisions...", - pr_number=context.pr_number, - ) - - # Run orchestrator session with tool calling - all_findings = [] - test_result = None - result_text = "" - tool_calls_made = [] - structured_output = None # For SDK structured outputs - - logger.info(f"[Orchestrator] Sending prompt (length: {len(prompt)} chars)") - logger.debug(f"[Orchestrator] Prompt preview: {prompt[:500]}...") - - async with client: - await client.query(prompt) - - print( - f"[Orchestrator] Waiting for LLM response ({model} with {thinking_level} thinking)...", - flush=True, - ) - if DEBUG_MODE: - print( - "[DEBUG Orchestrator] Starting to receive LLM response stream...", - flush=True, - ) - - message_count = 0 - thinking_received = False - text_started = False - - async for msg in client.receive_response(): - msg_type = type(msg).__name__ - message_count += 1 - logger.debug(f"[Orchestrator] Received message type: {msg_type}") - - # DEBUG: Log all message types received - if DEBUG_MODE: - print( - f"[DEBUG Orchestrator] Received message #{message_count}: {msg_type}", - flush=True, - ) - - # Handle extended thinking blocks (shows LLM reasoning) - if msg_type == "ThinkingBlock" or ( - hasattr(msg, "type") and msg.type == "thinking" - ): - if not thinking_received: - print( - "[Orchestrator] LLM is thinking (extended thinking)...", - flush=True, - ) - thinking_received = True - - thinking_text = ( - msg.thinking - if hasattr(msg, "thinking") - else getattr(msg, "text", "") - ) - if DEBUG_MODE and thinking_text: - print( - "[DEBUG Orchestrator] ===== LLM THINKING START =====", - flush=True, - ) - # Print thinking in chunks to avoid buffer issues - for i in range(0, len(thinking_text), 1000): - print(thinking_text[i : i + 1000], flush=True) - print( - "[DEBUG Orchestrator] ===== LLM THINKING END =====", - flush=True, - ) - else: - # Even without DEBUG, show thinking length - print( - f"[Orchestrator] Thinking block received ({len(thinking_text)} chars)", - flush=True, - ) - logger.debug( - f"[Orchestrator] Thinking block (length: {len(thinking_text)})" - ) - - # Handle text delta streaming (real-time output) - if msg_type == "TextDelta" or ( - hasattr(msg, "type") and msg.type == "text_delta" - ): - if not text_started: - print( - "[Orchestrator] LLM is generating response...", - flush=True, - ) - text_started = True - - delta_text = ( - msg.text - if hasattr(msg, "text") - else getattr(msg, "delta", "") - ) - if DEBUG_MODE and delta_text: - print(delta_text, end="", flush=True) - - # Handle tool calls from orchestrator - if msg_type == "ToolUseBlock" or ( - hasattr(msg, "type") and msg.type == "tool_use" - ): - tool_name = ( - msg.name - if hasattr(msg, "name") - else msg.tool_use.name - if hasattr(msg, "tool_use") - else "unknown" - ) - tool_calls_made.append(tool_name) - logger.info(f"[Orchestrator] Tool call detected: {tool_name}") - - # SDK delivers structured output via StructuredOutput tool - if tool_name == "StructuredOutput": - structured_data = getattr(msg, "input", None) - if structured_data: - structured_output = structured_data - logger.info( - "[Orchestrator] Found StructuredOutput tool use" - ) - print( - "[Orchestrator] Received SDK structured output", - flush=True, - ) - continue # No need to handle as regular tool - - tool_result = await self._handle_tool_call(msg, context) - # Tools already executed, agent will receive results - - logger.debug( - f"[Orchestrator] Tool result: {str(tool_result)[:200]}..." - ) - - # Track findings from subagents - if isinstance(tool_result, dict): - if "findings" in tool_result: - findings_count = len(tool_result["findings"]) - logger.info( - f"[Orchestrator] Tool returned {findings_count} findings" - ) - all_findings.extend(tool_result["findings"]) - if "test_result" in tool_result: - test_result = tool_result["test_result"] - logger.info( - f"[Orchestrator] Tool returned test result: {test_result.get('passed', 'unknown')}" - ) - - # Track token usage from response - if hasattr(msg, "usage"): - usage = msg.usage - tokens_used = getattr(usage, "input_tokens", 0) + getattr( - usage, "output_tokens", 0 - ) - self.total_tokens += tokens_used - logger.debug( - f"[Orchestrator] Token usage: +{tokens_used} (total: {self.total_tokens})" - ) - - # Collect final orchestrator output - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - if hasattr(block, "text"): - result_text += block.text - logger.debug( - f"[Orchestrator] Received text block (length: {len(block.text)})" - ) - # Also check for StructuredOutput in AssistantMessage content - if block_type == "ToolUseBlock": - tool_name = getattr(block, "name", "") - if tool_name == "StructuredOutput": - structured_data = getattr(block, "input", None) - if structured_data: - structured_output = structured_data - logger.info( - "[Orchestrator] Found StructuredOutput in AssistantMessage" - ) - print( - "[Orchestrator] Received SDK structured output", - flush=True, - ) - - # Check for structured output (SDK validated JSON) - if hasattr(msg, "structured_output") and msg.structured_output: - structured_output = msg.structured_output - logger.info( - "[Orchestrator] Received structured output from SDK" - ) - - logger.info( - f"[Orchestrator] Session complete. Tool calls made: {tool_calls_made}" - ) - logger.info( - f"[Orchestrator] Final text response length: {len(result_text)}" - ) - logger.debug(f"[Orchestrator] Final text preview: {result_text[:500]}...") - - # CRITICAL DEBUG: Print to ensure visibility - print( - f"[Orchestrator] Session complete. Tool calls: {tool_calls_made}", - flush=True, - ) - print( - f"[Orchestrator] Final text length: {len(result_text)} chars", - flush=True, - ) - print("[Orchestrator] ===== FULL OUTPUT START =====", flush=True) - print(result_text, flush=True) - print("[Orchestrator] ===== FULL OUTPUT END =====", flush=True) - - self._report_progress( - "finalizing", - 80, - "Generating verdict...", - pr_number=context.pr_number, - ) - - # Use structured output if available, otherwise fall back to parsing - if structured_output: - logger.info("[Orchestrator] Using validated structured output") - print("[Orchestrator] Using SDK structured output", flush=True) - orchestrator_findings = self._parse_structured_output(structured_output) - # Fallback to text parsing only if structured output parsing FAILED (None) - # An empty list means the PR is clean - don't trigger fallback - if orchestrator_findings is None and result_text: - logger.warning( - "[Orchestrator] Structured output parsing failed, falling back to text" - ) - print( - "[Orchestrator] Structured output failed, trying text parsing fallback", - flush=True, - ) - orchestrator_findings = self._parse_orchestrator_output(result_text) - elif orchestrator_findings is None: - orchestrator_findings = [] # No fallback available, use empty - else: - logger.info("[Orchestrator] Falling back to text parsing") - print("[Orchestrator] Falling back to text parsing", flush=True) - orchestrator_findings = self._parse_orchestrator_output(result_text) - all_findings.extend(orchestrator_findings) - - # Deduplicate findings - unique_findings = self._deduplicate_findings(all_findings) - - logger.info( - f"[Orchestrator] Review complete: {len(unique_findings)} findings" - ) - - # Generate verdict - verdict, verdict_reasoning, blockers = self._generate_verdict( - unique_findings, test_result - ) - - # Generate summary - summary = self._generate_summary( - verdict=verdict, - verdict_reasoning=verdict_reasoning, - blockers=blockers, - findings=unique_findings, - test_result=test_result, - ) - - # Map verdict to overall_status - if verdict == MergeVerdict.BLOCKED: - overall_status = "request_changes" - elif verdict == MergeVerdict.NEEDS_REVISION: - overall_status = "request_changes" - elif verdict == MergeVerdict.MERGE_WITH_CHANGES: - overall_status = "comment" - else: - overall_status = "approve" - - result = PRReviewResult( - pr_number=context.pr_number, - repo=self.config.repo, - success=True, - findings=unique_findings, - summary=summary, - overall_status=overall_status, - verdict=verdict, - verdict_reasoning=verdict_reasoning, - blockers=blockers, - ) - - print( - f"[Orchestrator] Returning PRReviewResult with {len(result.findings)} findings", - flush=True, - ) - print( - f"[Orchestrator] Verdict: {result.verdict.value if result.verdict else 'None'}", - flush=True, - ) - - self._report_progress( - "complete", 100, "Review complete!", pr_number=context.pr_number - ) - - return result - - except Exception as e: - logger.error(f"[Orchestrator] Review failed: {e}", exc_info=True) - result = PRReviewResult( - pr_number=context.pr_number, - repo=self.config.repo, - success=False, - error=str(e), - ) - return result - - async def _handle_tool_call(self, tool_msg, context: PRContext) -> dict[str, Any]: - """ - Handle tool calls from orchestrator. - - The orchestrator can call tools to spawn subagents, run tests, etc. - """ - # Extract tool name and arguments based on message type - if hasattr(tool_msg, "name"): - tool_name = tool_msg.name - tool_args = tool_msg.input if hasattr(tool_msg, "input") else {} - elif hasattr(tool_msg, "tool_use"): - tool_name = tool_msg.tool_use.name - tool_args = tool_msg.tool_use.input - else: - logger.warning("[Orchestrator] Unknown tool message format") - return {} - - logger.info(f"[Orchestrator] Tool call: {tool_name}") - - # Check token budget - if self.total_tokens > self.MAX_TOTAL_BUDGET: - logger.warning("[Orchestrator] Token budget exceeded, skipping tool") - return {"error": "Token budget exceeded"} - - try: - # Dispatch to appropriate tool - if tool_name == "spawn_security_review": - findings = await spawn_security_review( - files=tool_args.get("files", []), - focus_areas=tool_args.get("focus_areas", []), - pr_context=context, - project_dir=self.project_dir, - github_dir=self.github_dir, - ) - return {"findings": [f.__dict__ for f in findings]} - - elif tool_name == "spawn_quality_review": - findings = await spawn_quality_review( - files=tool_args.get("files", []), - focus_areas=tool_args.get("focus_areas", []), - pr_context=context, - project_dir=self.project_dir, - github_dir=self.github_dir, - ) - return {"findings": [f.__dict__ for f in findings]} - - elif tool_name == "spawn_deep_analysis": - findings = await spawn_deep_analysis( - files=tool_args.get("files", []), - focus_question=tool_args.get("focus_question", ""), - pr_context=context, - project_dir=self.project_dir, - github_dir=self.github_dir, - ) - return {"findings": [f.__dict__ for f in findings]} - - elif tool_name == "run_tests": - test_result = await run_tests( - project_dir=self.project_dir, - test_paths=tool_args.get("test_paths"), - ) - return {"test_result": test_result.__dict__} - - elif tool_name == "check_coverage": - coverage = await check_coverage( - project_dir=self.project_dir, - changed_files=[f.path for f in context.changed_files], - ) - return ( - {"coverage": coverage.__dict__} if coverage else {"coverage": None} - ) - - elif tool_name == "verify_path_exists": - path_result = await verify_path_exists( - project_dir=self.project_dir, - path=tool_args.get("path", ""), - ) - return {"path_check": path_result.__dict__} - - elif tool_name == "get_file_content": - content = await get_file_content( - project_dir=self.project_dir, - file_path=tool_args.get("file_path", ""), - ) - return {"content": content} - - else: - logger.warning(f"[Orchestrator] Unknown tool: {tool_name}") - return {"error": f"Unknown tool: {tool_name}"} - - except Exception as e: - logger.error(f"[Orchestrator] Tool {tool_name} failed: {e}") - return {"error": str(e)} - - def _build_orchestrator_prompt(self, context: PRContext) -> str: - """Build full prompt for orchestrator with PR context and tool definitions.""" - # Load orchestrator prompt - prompt_file = ( - Path(__file__).parent.parent.parent.parent - / "prompts" - / "github" - / "pr_orchestrator.md" - ) - - if prompt_file.exists(): - base_prompt = prompt_file.read_text(encoding="utf-8") - else: - logger.warning("Orchestrator prompt not found!") - base_prompt = "You are a PR reviewer. Review the provided PR." - - # Build PR context - files_list = [] - for file in context.changed_files: # Show ALL files - files_list.append( - f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}" - ) - - # Build composite diff from patches (use individual file patches when diff_truncated) - patches = [] - files_with_patches = 0 - MAX_DIFF_CHARS = 200_000 # Increase limit to 200K chars for large PRs - - for file in context.changed_files: # Process ALL files, not just first 50 - if file.patch: - patches.append(f"\n### File: {file.path}\n{file.patch}") - files_with_patches += 1 - - diff_content = "\n".join(patches) - - # Check if diff needs truncation - if len(diff_content) > MAX_DIFF_CHARS: - logger.warning( - f"[Orchestrator] Diff truncated from {len(diff_content)} to {MAX_DIFF_CHARS} chars" - ) - diff_content = ( - diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated due to size)" - ) - - logger.info( - f"[Orchestrator] Built context: {len(context.changed_files)} files total, {files_with_patches} with patches, {len(diff_content)} chars diff" - ) - - # Add truncation warning if needed - truncation_note = "" - if len(diff_content) >= MAX_DIFF_CHARS or context.diff_truncated: - truncation_note = f""" - -**⚠️ IMPORTANT:** This PR is very large. The diff shown below may be truncated. -- Files with patches: {files_with_patches}/{len(context.changed_files)} -- Use `get_file_content(file_path)` tool to fetch full content of specific files you want to review in depth. -""" - - pr_context = f""" ---- - -## PR Context for Review - -**PR Number:** {context.pr_number} -**Title:** {context.title} -**Author:** {context.author} -**Base:** {context.base_branch} ← **Head:** {context.head_branch} -**Files Changed:** {len(context.changed_files)} files -**Total Changes:** +{context.total_additions}/-{context.total_deletions} lines -{truncation_note} - -### Description -{context.description} - -### All Changed Files -{chr(10).join(files_list)} - -### Code Changes ({files_with_patches} files with patches) -```diff -{diff_content} -``` - ---- - -Now perform your strategic review and use the available tools to spawn subagents, run tests, etc. as needed. -""" - - return base_prompt + pr_context - - def _parse_structured_output( - self, structured_output: dict[str, Any] - ) -> list[PRReviewFinding] | None: - """ - Parse findings from SDK structured output. - - Uses the validated OrchestratorReviewResponse schema for type-safe parsing. - - Returns: - List of findings on success (may be empty for clean PRs), - None on parsing failure (triggers fallback to text parsing). - """ - findings = [] - - try: - # Validate with Pydantic - result = OrchestratorReviewResponse.model_validate(structured_output) - - logger.info( - f"[Orchestrator] Structured output: verdict={result.verdict}, " - f"{len(result.findings)} findings" - ) - - for f in result.findings: - # Generate unique ID for this finding - import hashlib - - finding_id = hashlib.md5( - f"{f.file}:{f.line}:{f.title}".encode(), - usedforsecurity=False, - ).hexdigest()[:12] - - # Map category using flexible mapping - category = _map_category(f.category) - - # Map severity - try: - severity = ReviewSeverity(f.severity.lower()) - except ValueError: - severity = ReviewSeverity.MEDIUM - - finding = PRReviewFinding( - id=finding_id, - file=f.file, - line=f.line, - title=f.title, - description=f.description, - category=category, - severity=severity, - suggested_fix=f.suggestion or "", - confidence=self._normalize_confidence(f.confidence), - ) - findings.append(finding) - logger.debug( - f"[Orchestrator] Added structured finding: {finding.title} ({finding.severity.value})" - ) - - print( - f"[Orchestrator] Processed {len(findings)} findings from structured output", - flush=True, - ) - - except Exception as e: - logger.error(f"[Orchestrator] Failed to parse structured output: {e}") - print(f"[Orchestrator] Structured output parsing failed: {e}", flush=True) - return None # Signal failure - triggers fallback to text parsing - - return findings - - def _parse_orchestrator_output(self, output: str) -> list[PRReviewFinding]: - """Parse findings from orchestrator's final output.""" - findings = [] - - logger.debug(f"[Orchestrator] Parsing output (length: {len(output)})") - print( - f"[Orchestrator] PARSING OUTPUT - Length: {len(output)} chars", flush=True - ) - - try: - # Strip markdown code blocks if present - # AI often wraps JSON in ```json ... ``` - import re - - # Find JSON in code blocks first - code_block_pattern = r"```(?:json)?\s*(\{[\s\S]*?\})\s*```" - code_block_match = re.search(code_block_pattern, output) - if code_block_match: - # Extract JSON from inside code block - json_candidate = code_block_match.group(1) - logger.debug( - f"[Orchestrator] Found JSON in code block (length: {len(json_candidate)})" - ) - try: - response_data = json.loads(json_candidate) - findings_data = response_data.get("findings", []) - logger.info( - f"[Orchestrator] Parsed {len(findings_data)} findings from code block" - ) - print( - f"[Orchestrator] Parsed JSON from code block - Verdict: {response_data.get('verdict', 'unknown')}", - flush=True, - ) - return self._extract_findings_from_data(findings_data) - except json.JSONDecodeError: - logger.debug( - "[Orchestrator] Code block JSON parse failed, trying raw extraction" - ) - - # Look for JSON object in output (orchestrator outputs full object, not just array) - start = output.find("{") - - # Find matching closing brace by counting braces - if start != -1: - brace_count = 0 - end = -1 - for i in range(start, len(output)): - if output[i] == "{": - brace_count += 1 - elif output[i] == "}": - brace_count -= 1 - if brace_count == 0: - end = i - break - - logger.debug( - f"[Orchestrator] JSON object positions: start={start}, end={end}" - ) - - if end != -1: - json_str = output[start : end + 1] - logger.debug( - f"[Orchestrator] Extracted JSON string (length: {len(json_str)})" - ) - logger.debug(f"[Orchestrator] JSON preview: {json_str[:200]}...") - - # Parse full orchestrator response - response_data = json.loads(json_str) - logger.info( - f"[Orchestrator] Parsed orchestrator response: {response_data.get('verdict', 'unknown')}" - ) - print( - f"[Orchestrator] Parsed JSON object - Verdict: {response_data.get('verdict', 'unknown')}", - flush=True, - ) - - # Extract findings array from response - findings_data = response_data.get("findings", []) - logger.info( - f"[Orchestrator] Found {len(findings_data)} finding(s) in response" - ) - print( - f"[Orchestrator] Extracted {len(findings_data)} findings from response", - flush=True, - ) - - # Process findings from JSON object - for idx, data in enumerate(findings_data): - # Generate unique ID for this finding - import hashlib - - finding_id = hashlib.md5( - f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(), - usedforsecurity=False, - ).hexdigest()[:12] - - # Map category using flexible mapping (handles AI-generated values) - category = _map_category(data.get("category", "quality")) - - # Map severity with fallback - try: - severity = ReviewSeverity( - data.get("severity", "medium").lower() - ) - except ValueError: - severity = ReviewSeverity.MEDIUM - - finding = PRReviewFinding( - id=finding_id, - file=data.get("file", "unknown"), - line=data.get("line", 0), - title=data.get("title", "Untitled"), - description=data.get("description", ""), - category=category, - severity=severity, - suggested_fix=data.get( - "suggestion", data.get("suggested_fix", "") - ), - confidence=self._normalize_confidence( - data.get("confidence", 85) - ), - ) - findings.append(finding) - logger.debug( - f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})" - ) - - print( - f"[Orchestrator] Processed {len(findings)} findings from JSON object", - flush=True, - ) - else: - logger.warning( - "[Orchestrator] Could not find matching closing brace" - ) - return findings - elif output.find("[") != -1: - # Fallback: Try to parse as array (old format) - start = output.find("[") - end = output.rfind("]") - logger.debug( - f"[Orchestrator] Fallback to array parsing: start={start}, end={end}" - ) - - if start != -1 and end != -1: - json_str = output[start : end + 1] - logger.debug( - f"[Orchestrator] Extracted JSON array (length: {len(json_str)})" - ) - findings_data = json.loads(json_str) - logger.info( - f"[Orchestrator] Parsed {len(findings_data)} finding(s) from array" - ) - - for idx, data in enumerate(findings_data): - # Generate unique ID for this finding - import hashlib - - finding_id = hashlib.md5( - f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(), - usedforsecurity=False, - ).hexdigest()[:12] - - # Map category using flexible mapping (handles AI-generated values) - category = _map_category(data.get("category", "quality")) - - # Map severity with fallback - try: - severity = ReviewSeverity( - data.get("severity", "medium").lower() - ) - except ValueError: - severity = ReviewSeverity.MEDIUM - - finding = PRReviewFinding( - id=finding_id, - file=data.get("file", "unknown"), - line=data.get("line", 0), - title=data.get("title", "Untitled"), - description=data.get("description", ""), - category=category, - severity=severity, - suggested_fix=data.get( - "suggestion", data.get("suggested_fix", "") - ), - confidence=self._normalize_confidence( - data.get("confidence", 85) - ), - ) - findings.append(finding) - logger.debug( - f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})" - ) - - else: - logger.warning("[Orchestrator] No JSON array found in output") - - except Exception as e: - logger.error(f"[Orchestrator] Failed to parse output: {e}", exc_info=True) - - logger.info(f"[Orchestrator] Parsed {len(findings)} total findings from output") - return findings - - def _normalize_confidence(self, confidence_value: int | float) -> float: - """ - Normalize confidence value to 0.0-1.0 range. - - AI models may return confidence as: - - Percentage (0-100): divide by 100 - - Decimal (0.0-1.0): use as-is - - Args: - confidence_value: Raw confidence value from AI output - - Returns: - Normalized confidence as float in 0.0-1.0 range - """ - if confidence_value > 1: - # Percentage format (e.g., 85 -> 0.85) - return confidence_value / 100.0 - else: - # Already decimal format (e.g., 0.85) - return float(confidence_value) - - def _extract_findings_from_data( - self, findings_data: list[dict] - ) -> list[PRReviewFinding]: - """ - Extract PRReviewFinding objects from parsed JSON findings data. - - Args: - findings_data: List of finding dictionaries from JSON - - Returns: - List of PRReviewFinding objects - """ - import hashlib - - findings = [] - for data in findings_data: - # Generate unique ID for this finding - finding_id = hashlib.md5( - f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(), - usedforsecurity=False, - ).hexdigest()[:12] - - # Map category using flexible mapping (handles AI-generated values) - category = _map_category(data.get("category", "quality")) - - # Map severity with fallback - try: - severity = ReviewSeverity(data.get("severity", "medium").lower()) - except ValueError: - severity = ReviewSeverity.MEDIUM - - finding = PRReviewFinding( - id=finding_id, - file=data.get("file", "unknown"), - line=data.get("line", 0), - title=data.get("title", "Untitled"), - description=data.get("description", ""), - category=category, - severity=severity, - suggested_fix=data.get("suggestion", data.get("suggested_fix", "")), - confidence=self._normalize_confidence(data.get("confidence", 85)), - ) - findings.append(finding) - logger.debug( - f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})" - ) - - return findings - - def _deduplicate_findings( - self, findings: list[PRReviewFinding] - ) -> list[PRReviewFinding]: - """Remove duplicate findings.""" - seen = set() - unique = [] - - for f in findings: - key = (f.file, f.line, f.title.lower().strip()) - if key not in seen: - seen.add(key) - unique.append(f) - - return unique - - def _generate_verdict( - self, findings: list[PRReviewFinding], test_result - ) -> tuple[MergeVerdict, str, list[str]]: - """Generate merge verdict based on findings and test results.""" - blockers = [] - - # Count by severity - critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL] - high = [f for f in findings if f.severity == ReviewSeverity.HIGH] - - # Tests failing is always a blocker - if test_result and not test_result.passed: - blockers.append(f"Tests failing: {test_result.error or 'Unknown error'}") - - # Critical findings are blockers - for f in critical: - blockers.append(f"Critical: {f.title} ({f.file}:{f.line})") - - # Determine verdict - if blockers or (test_result and not test_result.passed): - verdict = MergeVerdict.BLOCKED - reasoning = f"Blocked by {len(blockers)} critical issue(s)" - elif high: - verdict = MergeVerdict.NEEDS_REVISION - reasoning = f"{len(high)} high-priority issues must be addressed" - elif len(findings) > 0: - verdict = MergeVerdict.MERGE_WITH_CHANGES - reasoning = f"{len(findings)} issues to address" - else: - verdict = MergeVerdict.READY_TO_MERGE - reasoning = "No blocking issues found" - - return verdict, reasoning, blockers - - def _generate_summary( - self, - verdict: MergeVerdict, - verdict_reasoning: str, - blockers: list[str], - findings: list[PRReviewFinding], - test_result, - ) -> str: - """Generate PR review summary.""" - verdict_emoji = { - MergeVerdict.READY_TO_MERGE: "✅", - MergeVerdict.MERGE_WITH_CHANGES: "🟡", - MergeVerdict.NEEDS_REVISION: "🟠", - MergeVerdict.BLOCKED: "🔴", - } - - lines = [ - f"### Merge Verdict: {verdict_emoji.get(verdict, '⚪')} {verdict.value.upper().replace('_', ' ')}", - verdict_reasoning, - "", - ] - - # Test results - if test_result: - if test_result.passed: - lines.append("✅ **Tests**: All tests passing") - else: - lines.append( - f"❌ **Tests**: Failed - {test_result.error or 'See logs'}" - ) - lines.append("") - - # Blockers - if blockers: - lines.append("### 🚨 Blocking Issues") - for blocker in blockers: - lines.append(f"- {blocker}") - lines.append("") - - # Findings summary - if findings: - by_severity = {} - for f in findings: - severity = f.severity.value - if severity not in by_severity: - by_severity[severity] = [] - by_severity[severity].append(f) - - lines.append("### Findings Summary") - for severity in ["critical", "high", "medium", "low"]: - if severity in by_severity: - count = len(by_severity[severity]) - lines.append(f"- **{severity.capitalize()}**: {count} issue(s)") - lines.append("") - - lines.append("---") - lines.append("_Generated by Auto Claude Orchestrating PR Reviewer (Opus 4.5)_") - - return "\n".join(lines) diff --git a/apps/backend/runners/github/services/parallel_followup_reviewer.py b/apps/backend/runners/github/services/parallel_followup_reviewer.py new file mode 100644 index 00000000..20093010 --- /dev/null +++ b/apps/backend/runners/github/services/parallel_followup_reviewer.py @@ -0,0 +1,752 @@ +""" +Parallel Follow-up PR Reviewer +=============================== + +PR follow-up reviewer using Claude Agent SDK subagents for parallel specialist analysis. + +The orchestrator analyzes incremental changes and delegates to specialized agents: +- resolution-verifier: Verifies previous findings are addressed +- new-code-reviewer: Reviews new code for issues +- comment-analyzer: Processes contributor and AI feedback + +Key Design: +- AI decides which agents to invoke (NOT programmatic rules) +- Subagents defined via SDK `agents={}` parameter +- SDK handles parallel execution automatically +- User-configured model from frontend settings (no hardcoding) +""" + +from __future__ import annotations + +import hashlib +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..models import FollowupReviewContext + +from claude_agent_sdk import AgentDefinition + +try: + from ...core.client import create_client + from ...phase_config import get_thinking_budget + from ..models import ( + GitHubRunnerConfig, + MergeVerdict, + PRReviewFinding, + PRReviewResult, + ReviewSeverity, + ) + from .category_utils import map_category + from .pydantic_models import ParallelFollowupResponse + from .sdk_utils import process_sdk_stream +except (ImportError, ValueError, SystemError): + from core.client import create_client + from models import ( + GitHubRunnerConfig, + MergeVerdict, + PRReviewFinding, + PRReviewResult, + ReviewSeverity, + ) + from phase_config import get_thinking_budget + from services.category_utils import map_category + from services.pydantic_models import ParallelFollowupResponse + from services.sdk_utils import process_sdk_stream + + +logger = logging.getLogger(__name__) + +# Check if debug mode is enabled +DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes") + +# Severity mapping for AI responses +_SEVERITY_MAPPING = { + "critical": ReviewSeverity.CRITICAL, + "high": ReviewSeverity.HIGH, + "medium": ReviewSeverity.MEDIUM, + "low": ReviewSeverity.LOW, +} + + +def _map_severity(severity_str: str) -> ReviewSeverity: + """Map severity string to ReviewSeverity enum.""" + return _SEVERITY_MAPPING.get(severity_str.lower(), ReviewSeverity.MEDIUM) + + +class ParallelFollowupReviewer: + """ + Follow-up PR reviewer using SDK subagents for parallel specialist analysis. + + The orchestrator: + 1. Analyzes incremental changes since last review + 2. Delegates to appropriate specialist agents (SDK handles parallel execution) + 3. Synthesizes findings into a final merge verdict + + Specialist Agents: + - resolution-verifier: Verifies previous findings are addressed + - new-code-reviewer: Reviews new code for issues + - comment-analyzer: Processes contributor and AI feedback + + Model Configuration: + - Orchestrator uses user-configured model from frontend settings + - Specialist agents use model="inherit" (same as orchestrator) + """ + + def __init__( + self, + project_dir: Path, + github_dir: Path, + config: GitHubRunnerConfig, + progress_callback=None, + ): + self.project_dir = Path(project_dir) + self.github_dir = Path(github_dir) + self.config = config + self.progress_callback = progress_callback + + def _report_progress(self, phase: str, progress: int, message: str, **kwargs): + """Report progress if callback is set.""" + if self.progress_callback: + import sys + + if "orchestrator" in sys.modules: + ProgressCallback = sys.modules["orchestrator"].ProgressCallback + else: + try: + from ..orchestrator import ProgressCallback + except ImportError: + from orchestrator import ProgressCallback + + self.progress_callback( + ProgressCallback( + phase=phase, progress=progress, message=message, **kwargs + ) + ) + + def _load_prompt(self, filename: str) -> str: + """Load a prompt file from the prompts/github directory.""" + prompt_file = ( + Path(__file__).parent.parent.parent.parent / "prompts" / "github" / filename + ) + if prompt_file.exists(): + return prompt_file.read_text(encoding="utf-8") + logger.warning(f"Prompt file not found: {prompt_file}") + return "" + + def _define_specialist_agents(self) -> dict[str, AgentDefinition]: + """ + Define specialist agents for follow-up review. + + Each agent has: + - description: When the orchestrator should invoke this agent + - prompt: System prompt for the agent + - tools: Tools the agent can use (read-only for PR review) + - model: "inherit" = use same model as orchestrator (user's choice) + """ + # Load agent prompts from files + resolution_prompt = self._load_prompt("pr_followup_resolution_agent.md") + newcode_prompt = self._load_prompt("pr_followup_newcode_agent.md") + comment_prompt = self._load_prompt("pr_followup_comment_agent.md") + + return { + "resolution-verifier": AgentDefinition( + description=( + "Resolution verification specialist. Use to verify whether previous " + "findings have been addressed. Analyzes diffs to determine if issues " + "are truly fixed, partially fixed, or still unresolved. " + "Invoke when: There are previous findings to verify." + ), + prompt=resolution_prompt + or "You verify whether previous findings are resolved.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + "new-code-reviewer": AgentDefinition( + description=( + "New code analysis specialist. Reviews code added since last review " + "for security, logic, quality issues, and regressions. " + "Invoke when: There are substantial code changes (>50 lines diff) or " + "changes to security-sensitive areas." + ), + prompt=newcode_prompt or "You review new code for issues.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + "comment-analyzer": AgentDefinition( + description=( + "Comment and feedback analyst. Processes contributor comments and " + "AI tool reviews (CodeRabbit, Cursor, Gemini, etc.) to identify " + "unanswered questions and valid concerns. " + "Invoke when: There are comments or formal reviews since last review." + ), + prompt=comment_prompt or "You analyze comments and feedback.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + } + + def _format_previous_findings(self, context: FollowupReviewContext) -> str: + """Format previous findings for the prompt.""" + previous_findings = context.previous_review.findings + if not previous_findings: + return "No previous findings to verify." + + lines = [] + for f in previous_findings: + lines.append( + f"- **{f.id}** [{f.severity.value}] {f.title}\n" + f" File: {f.file}:{f.line}\n" + f" {f.description[:200]}..." + ) + return "\n".join(lines) + + def _format_commits(self, context: FollowupReviewContext) -> str: + """Format new commits for the prompt.""" + if not context.commits_since_review: + return "No new commits." + + lines = [] + for commit in context.commits_since_review[:20]: # Limit to 20 commits + sha = commit.get("sha", "")[:7] + message = commit.get("commit", {}).get("message", "").split("\n")[0] + author = commit.get("commit", {}).get("author", {}).get("name", "unknown") + lines.append(f"- `{sha}` by {author}: {message}") + return "\n".join(lines) + + def _format_comments(self, context: FollowupReviewContext) -> str: + """Format contributor comments for the prompt.""" + if not context.contributor_comments_since_review: + return "No contributor comments since last review." + + lines = [] + for comment in context.contributor_comments_since_review[:15]: + author = comment.get("user", {}).get("login", "unknown") + body = comment.get("body", "")[:300] + lines.append(f"**@{author}**: {body}") + return "\n\n".join(lines) + + def _format_ai_reviews(self, context: FollowupReviewContext) -> str: + """Format AI bot reviews and comments for the prompt.""" + ai_content = [] + + # AI bot comments + for comment in context.ai_bot_comments_since_review[:10]: + author = comment.get("user", {}).get("login", "unknown") + body = comment.get("body", "")[:500] + ai_content.append(f"**{author}** (comment):\n{body}") + + # Formal PR reviews from AI tools + for review in context.pr_reviews_since_review[:5]: + author = review.get("user", {}).get("login", "unknown") + body = review.get("body", "")[:1000] + state = review.get("state", "unknown") + ai_content.append(f"**{author}** ({state}):\n{body}") + + if not ai_content: + return "No AI tool feedback since last review." + + return "\n\n---\n\n".join(ai_content) + + def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str: + """Build full prompt for orchestrator with follow-up context.""" + # Load orchestrator prompt + base_prompt = self._load_prompt("pr_followup_orchestrator.md") + if not base_prompt: + base_prompt = "You are a follow-up PR reviewer. Verify resolutions and find new issues." + + # Build context sections + previous_findings = self._format_previous_findings(context) + commits = self._format_commits(context) + contributor_comments = self._format_comments(context) + ai_reviews = self._format_ai_reviews(context) + + # Truncate diff if too long + MAX_DIFF_CHARS = 100_000 + diff_content = context.diff_since_review + if len(diff_content) > MAX_DIFF_CHARS: + diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)" + + followup_context = f""" +--- + +## Follow-up Review Context + +**PR Number:** {context.pr_number} +**Previous Review Commit:** {context.previous_commit_sha[:8]} +**Current HEAD:** {context.current_commit_sha[:8]} +**New Commits:** {len(context.commits_since_review)} +**Files Changed:** {len(context.files_changed_since_review)} + +### Previous Review Summary +{context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."} + +### Previous Findings to Verify +{previous_findings} + +### New Commits Since Last Review +{commits} + +### Files Changed Since Last Review +{chr(10).join(f"- {f}" for f in context.files_changed_since_review[:30])} + +### Contributor Comments Since Last Review +{contributor_comments} + +### AI Tool Feedback Since Last Review +{ai_reviews} + +### Diff Since Last Review +```diff +{diff_content} +``` + +--- + +Now analyze this follow-up and delegate to the appropriate specialist agents. +Remember: YOU decide which agents to invoke based on YOUR analysis. +The SDK will run invoked agents in parallel automatically. +""" + + return base_prompt + followup_context + + async def review(self, context: FollowupReviewContext) -> PRReviewResult: + """ + Main follow-up review entry point. + + Args: + context: Follow-up context with incremental changes + + Returns: + PRReviewResult with findings and verdict + """ + logger.info( + f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}" + ) + + try: + self._report_progress( + "orchestrating", + 35, + "Parallel orchestrator analyzing follow-up...", + pr_number=context.pr_number, + ) + + # Build orchestrator prompt + prompt = self._build_orchestrator_prompt(context) + + # Get project root + project_root = ( + self.project_dir.parent.parent + if self.project_dir.name == "backend" + else self.project_dir + ) + + # Use model and thinking level from config (user settings) + model = self.config.model or "claude-sonnet-4-5-20250929" + thinking_level = self.config.thinking_level or "medium" + thinking_budget = get_thinking_budget(thinking_level) + + logger.info( + f"[ParallelFollowup] Using model={model}, " + f"thinking_level={thinking_level}, thinking_budget={thinking_budget}" + ) + + # Create client with subagents defined + client = create_client( + project_dir=project_root, + spec_dir=self.github_dir, + model=model, + agent_type="pr_followup_parallel", + max_thinking_tokens=thinking_budget, + agents=self._define_specialist_agents(), + output_format={ + "type": "json_schema", + "schema": ParallelFollowupResponse.model_json_schema(), + }, + ) + + self._report_progress( + "orchestrating", + 40, + "Orchestrator delegating to specialist agents...", + pr_number=context.pr_number, + ) + + # Run orchestrator session using shared SDK stream processor + async with client: + await client.query(prompt) + + print( + f"[ParallelFollowup] Running orchestrator ({model})...", + flush=True, + ) + + # Process SDK stream with shared utility + stream_result = await process_sdk_stream( + client=client, + context_name="ParallelFollowup", + ) + + # Check for stream processing errors + if stream_result.get("error"): + logger.error( + f"[ParallelFollowup] SDK stream failed: {stream_result['error']}" + ) + raise RuntimeError( + f"SDK stream processing failed: {stream_result['error']}" + ) + + result_text = stream_result["result_text"] + structured_output = stream_result["structured_output"] + agents_invoked = stream_result["agents_invoked"] + msg_count = stream_result["msg_count"] + + self._report_progress( + "finalizing", + 50, + "Synthesizing follow-up findings...", + pr_number=context.pr_number, + ) + + # Parse findings from output + if structured_output: + result_data = self._parse_structured_output(structured_output, context) + else: + result_data = self._parse_text_output(result_text, context) + + # Extract data + findings = result_data.get("findings", []) + resolved_ids = result_data.get("resolved_ids", []) + unresolved_ids = result_data.get("unresolved_ids", []) + new_finding_ids = result_data.get("new_finding_ids", []) + verdict = result_data.get("verdict", MergeVerdict.NEEDS_REVISION) + verdict_reasoning = result_data.get("verdict_reasoning", "") + + # Use agents from structured output (more reliable than streaming detection) + agents_from_result = result_data.get("agents_invoked", []) + final_agents = agents_from_result if agents_from_result else agents_invoked + logger.info( + f"[ParallelFollowup] Session complete. Agents invoked: {final_agents}" + ) + print( + f"[ParallelFollowup] Complete. Agents invoked: {final_agents}", + flush=True, + ) + + # Deduplicate findings + unique_findings = self._deduplicate_findings(findings) + + logger.info( + f"[ParallelFollowup] Review complete: {len(unique_findings)} findings, " + f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved" + ) + + # Generate summary + summary = self._generate_summary( + verdict=verdict, + verdict_reasoning=verdict_reasoning, + resolved_count=len(resolved_ids), + unresolved_count=len(unresolved_ids), + new_count=len(new_finding_ids), + agents_invoked=final_agents, + ) + + # Map verdict to overall_status + if verdict == MergeVerdict.BLOCKED: + overall_status = "request_changes" + elif verdict == MergeVerdict.NEEDS_REVISION: + overall_status = "request_changes" + elif verdict == MergeVerdict.MERGE_WITH_CHANGES: + overall_status = "comment" + else: + overall_status = "approve" + + # Generate blockers from critical/high/medium severity findings + # (Medium also blocks merge in our strict quality gates approach) + blockers = [] + for finding in unique_findings: + if finding.severity in ( + ReviewSeverity.CRITICAL, + ReviewSeverity.HIGH, + ReviewSeverity.MEDIUM, + ): + blockers.append(f"{finding.category.value}: {finding.title}") + + result = PRReviewResult( + pr_number=context.pr_number, + repo=self.config.repo, + success=True, + findings=unique_findings, + summary=summary, + overall_status=overall_status, + verdict=verdict, + verdict_reasoning=verdict_reasoning, + blockers=blockers, + reviewed_commit_sha=context.current_commit_sha, + is_followup_review=True, + previous_review_id=context.previous_review.review_id + or context.previous_review.pr_number, + resolved_findings=resolved_ids, + unresolved_findings=unresolved_ids, + new_findings_since_last_review=new_finding_ids, + ) + + self._report_progress( + "analyzed", + 60, + "Follow-up analysis complete", + pr_number=context.pr_number, + ) + + return result + + except Exception as e: + logger.error(f"[ParallelFollowup] Review failed: {e}", exc_info=True) + print(f"[ParallelFollowup] Error: {e}", flush=True) + + return PRReviewResult( + pr_number=context.pr_number, + repo=self.config.repo, + success=False, + findings=[], + summary=f"Follow-up review failed: {e}", + overall_status="comment", + verdict=MergeVerdict.NEEDS_REVISION, + verdict_reasoning=f"Review failed: {e}", + blockers=[str(e)], + is_followup_review=True, + reviewed_commit_sha=context.current_commit_sha, + ) + + def _parse_structured_output( + self, data: dict, context: FollowupReviewContext + ) -> dict: + """Parse structured output from ParallelFollowupResponse.""" + try: + # Validate with Pydantic + response = ParallelFollowupResponse.model_validate(data) + + # Log agents from structured output + agents_from_output = response.agents_invoked or [] + if agents_from_output: + print( + f"[ParallelFollowup] Specialist agents invoked: {', '.join(agents_from_output)}", + flush=True, + ) + for agent in agents_from_output: + print(f"[Agent:{agent}] Analysis complete", flush=True) + + findings = [] + resolved_ids = [] + unresolved_ids = [] + new_finding_ids = [] + + # Process resolution verifications + for rv in response.resolution_verifications: + if rv.status == "resolved": + resolved_ids.append(rv.finding_id) + elif rv.status in ("unresolved", "partially_resolved", "cant_verify"): + # Include "cant_verify" as unresolved - if we can't verify, assume not fixed + unresolved_ids.append(rv.finding_id) + # Add unresolved as a finding + if rv.status in ("unresolved", "cant_verify"): + # Find original finding + original = next( + ( + f + for f in context.previous_review.findings + if f.id == rv.finding_id + ), + None, + ) + if original: + findings.append( + PRReviewFinding( + id=rv.finding_id, + severity=original.severity, + category=original.category, + title=f"[UNRESOLVED] {original.title}", + description=f"{original.description}\n\nResolution note: {rv.evidence}", + file=original.file, + line=original.line, + suggested_fix=original.suggested_fix, + fixable=original.fixable, + ) + ) + + # Process new findings + for nf in response.new_findings: + finding_id = nf.id or self._generate_finding_id( + nf.file, nf.line, nf.title + ) + new_finding_ids.append(finding_id) + findings.append( + PRReviewFinding( + id=finding_id, + severity=_map_severity(nf.severity), + category=map_category(nf.category), + title=nf.title, + description=nf.description, + file=nf.file, + line=nf.line, + suggested_fix=nf.suggested_fix, + fixable=nf.fixable, + ) + ) + + # Process comment findings + for cf in response.comment_findings: + finding_id = cf.id or self._generate_finding_id( + cf.file, cf.line, cf.title + ) + new_finding_ids.append(finding_id) + findings.append( + PRReviewFinding( + id=finding_id, + severity=_map_severity(cf.severity), + category=map_category(cf.category), + title=f"[FROM COMMENTS] {cf.title}", + description=cf.description, + file=cf.file, + line=cf.line, + suggested_fix=cf.suggested_fix, + fixable=cf.fixable, + ) + ) + + # Map verdict + verdict_map = { + "READY_TO_MERGE": MergeVerdict.READY_TO_MERGE, + "MERGE_WITH_CHANGES": MergeVerdict.MERGE_WITH_CHANGES, + "NEEDS_REVISION": MergeVerdict.NEEDS_REVISION, + "BLOCKED": MergeVerdict.BLOCKED, + } + verdict = verdict_map.get(response.verdict, MergeVerdict.NEEDS_REVISION) + + # Log findings summary for verification + print( + f"[ParallelFollowup] Parsed {len(findings)} findings, " + f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved, " + f"{len(new_finding_ids)} new", + flush=True, + ) + if findings: + print("[ParallelFollowup] Findings summary:", flush=True) + for i, f in enumerate(findings, 1): + print( + f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line})", + flush=True, + ) + + return { + "findings": findings, + "resolved_ids": resolved_ids, + "unresolved_ids": unresolved_ids, + "new_finding_ids": new_finding_ids, + "verdict": verdict, + "verdict_reasoning": response.verdict_reasoning, + "agents_invoked": agents_from_output, + } + + except Exception as e: + logger.warning(f"[ParallelFollowup] Failed to parse structured output: {e}") + return self._create_empty_result() + + def _parse_text_output(self, text: str, context: FollowupReviewContext) -> dict: + """Parse text output when structured output fails.""" + logger.warning("[ParallelFollowup] Falling back to text parsing") + + # Simple heuristic parsing + findings = [] + + # Look for verdict keywords + text_lower = text.lower() + if "ready to merge" in text_lower or "approve" in text_lower: + verdict = MergeVerdict.READY_TO_MERGE + elif "blocked" in text_lower or "critical" in text_lower: + verdict = MergeVerdict.BLOCKED + elif "needs revision" in text_lower or "request changes" in text_lower: + verdict = MergeVerdict.NEEDS_REVISION + else: + verdict = MergeVerdict.MERGE_WITH_CHANGES + + return { + "findings": findings, + "resolved_ids": [], + "unresolved_ids": [], + "new_finding_ids": [], + "verdict": verdict, + "verdict_reasoning": text[:500] if text else "Unable to parse response", + } + + def _create_empty_result(self) -> dict: + """Create empty result structure.""" + return { + "findings": [], + "resolved_ids": [], + "unresolved_ids": [], + "new_finding_ids": [], + "verdict": MergeVerdict.NEEDS_REVISION, + "verdict_reasoning": "Unable to parse review results", + } + + def _generate_finding_id(self, file: str, line: int, title: str) -> str: + """Generate a unique finding ID.""" + content = f"{file}:{line}:{title}" + return f"FU-{hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:8].upper()}" + + def _deduplicate_findings( + self, findings: list[PRReviewFinding] + ) -> list[PRReviewFinding]: + """Remove duplicate findings.""" + seen = set() + unique = [] + for f in findings: + key = (f.file, f.line, f.title.lower().strip()) + if key not in seen: + seen.add(key) + unique.append(f) + return unique + + def _generate_summary( + self, + verdict: MergeVerdict, + verdict_reasoning: str, + resolved_count: int, + unresolved_count: int, + new_count: int, + agents_invoked: list[str], + ) -> str: + """Generate a human-readable summary of the follow-up review.""" + status_emoji = { + MergeVerdict.READY_TO_MERGE: "✅", + MergeVerdict.MERGE_WITH_CHANGES: "⚠️", + MergeVerdict.NEEDS_REVISION: "🔄", + MergeVerdict.BLOCKED: "🚫", + } + + emoji = status_emoji.get(verdict, "📝") + agents_str = ( + ", ".join(agents_invoked) if agents_invoked else "orchestrator only" + ) + + summary = f"""## {emoji} Follow-up Review: {verdict.value.replace("_", " ").title()} + +### Resolution Status +- ✅ **Resolved**: {resolved_count} previous findings addressed +- ❌ **Unresolved**: {unresolved_count} previous findings remain +- 🆕 **New Issues**: {new_count} new findings in recent changes + +### Verdict +{verdict_reasoning} + +### Review Process +Agents invoked: {agents_str} + +--- +*This is an AI-generated follow-up review using parallel specialist analysis.* +""" + return summary diff --git a/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py b/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py new file mode 100644 index 00000000..99b2afb5 --- /dev/null +++ b/apps/backend/runners/github/services/parallel_orchestrator_reviewer.py @@ -0,0 +1,808 @@ +""" +Parallel Orchestrator PR Reviewer +================================== + +PR reviewer using Claude Agent SDK subagents for parallel specialist analysis. + +The orchestrator analyzes the PR and delegates to specialized agents (security, +quality, logic, codebase-fit, ai-triage) which run in parallel. Results are +synthesized into a final verdict. + +Key Design: +- AI decides which agents to invoke (NOT programmatic rules) +- Subagents defined via SDK `agents={}` parameter +- SDK handles parallel execution automatically +- User-configured model from frontend settings (no hardcoding) +""" + +from __future__ import annotations + +import hashlib +import logging +import os +from pathlib import Path +from typing import Any + +from claude_agent_sdk import AgentDefinition + +try: + from ...core.client import create_client + from ...phase_config import get_thinking_budget + from ..context_gatherer import PRContext + from ..models import ( + GitHubRunnerConfig, + MergeVerdict, + PRReviewFinding, + PRReviewResult, + ReviewSeverity, + ) + from .category_utils import map_category + from .pydantic_models import ParallelOrchestratorResponse + from .sdk_utils import process_sdk_stream +except (ImportError, ValueError, SystemError): + from context_gatherer import PRContext + from core.client import create_client + from models import ( + GitHubRunnerConfig, + MergeVerdict, + PRReviewFinding, + PRReviewResult, + ReviewSeverity, + ) + from phase_config import get_thinking_budget + from services.category_utils import map_category + from services.pydantic_models import ParallelOrchestratorResponse + from services.sdk_utils import process_sdk_stream + + +logger = logging.getLogger(__name__) + +# Check if debug mode is enabled +DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes") + + +class ParallelOrchestratorReviewer: + """ + PR reviewer using SDK subagents for parallel specialist analysis. + + The orchestrator: + 1. Analyzes the PR (size, complexity, file types, risk areas) + 2. Delegates to appropriate specialist agents (SDK handles parallel execution) + 3. Synthesizes findings into a final verdict + + Model Configuration: + - Orchestrator uses user-configured model from frontend settings + - Specialist agents use model="inherit" (same as orchestrator) + """ + + def __init__( + self, + project_dir: Path, + github_dir: Path, + config: GitHubRunnerConfig, + progress_callback=None, + ): + self.project_dir = Path(project_dir) + self.github_dir = Path(github_dir) + self.config = config + self.progress_callback = progress_callback + + def _report_progress(self, phase: str, progress: int, message: str, **kwargs): + """Report progress if callback is set.""" + if self.progress_callback: + import sys + + if "orchestrator" in sys.modules: + ProgressCallback = sys.modules["orchestrator"].ProgressCallback + else: + try: + from ..orchestrator import ProgressCallback + except ImportError: + from orchestrator import ProgressCallback + + self.progress_callback( + ProgressCallback( + phase=phase, progress=progress, message=message, **kwargs + ) + ) + + def _load_prompt(self, filename: str) -> str: + """Load a prompt file from the prompts/github directory.""" + prompt_file = ( + Path(__file__).parent.parent.parent.parent / "prompts" / "github" / filename + ) + if prompt_file.exists(): + return prompt_file.read_text(encoding="utf-8") + logger.warning(f"Prompt file not found: {prompt_file}") + return "" + + def _define_specialist_agents(self) -> dict[str, AgentDefinition]: + """ + Define specialist agents for the SDK. + + Each agent has: + - description: When the orchestrator should invoke this agent + - prompt: System prompt for the agent + - tools: Tools the agent can use (read-only for PR review) + - model: "inherit" = use same model as orchestrator (user's choice) + + Returns AgentDefinition dataclass instances as required by the SDK. + """ + # Load agent prompts from files + security_prompt = self._load_prompt("pr_security_agent.md") + quality_prompt = self._load_prompt("pr_quality_agent.md") + logic_prompt = self._load_prompt("pr_logic_agent.md") + codebase_fit_prompt = self._load_prompt("pr_codebase_fit_agent.md") + ai_triage_prompt = self._load_prompt("pr_ai_triage.md") + + return { + "security-reviewer": AgentDefinition( + description=( + "Security specialist. Use for OWASP Top 10, authentication, " + "injection, cryptographic issues, and sensitive data exposure. " + "Invoke when PR touches auth, API endpoints, user input, database queries, " + "or file operations." + ), + prompt=security_prompt + or "You are a security expert. Find vulnerabilities.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + "quality-reviewer": AgentDefinition( + description=( + "Code quality expert. Use for complexity, duplication, error handling, " + "maintainability, and pattern adherence. Invoke when PR has complex logic, " + "large functions, or significant business logic changes." + ), + prompt=quality_prompt + or "You are a code quality expert. Find quality issues.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + "logic-reviewer": AgentDefinition( + description=( + "Logic and correctness specialist. Use for algorithm verification, " + "edge cases, state management, and race conditions. Invoke when PR has " + "algorithmic changes, data transformations, concurrent operations, or bug fixes." + ), + prompt=logic_prompt + or "You are a logic expert. Find correctness issues.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + "codebase-fit-reviewer": AgentDefinition( + description=( + "Codebase consistency expert. Use for naming conventions, ecosystem fit, " + "architectural alignment, and avoiding reinvention. Invoke when PR introduces " + "new patterns, large additions, or code that might duplicate existing functionality." + ), + prompt=codebase_fit_prompt + or "You are a codebase expert. Check for consistency.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + "ai-triage-reviewer": AgentDefinition( + description=( + "AI comment validator. Use for triaging comments from CodeRabbit, " + "Gemini Code Assist, Cursor, Greptile, and other AI reviewers. " + "Invoke when PR has existing AI review comments that need validation." + ), + prompt=ai_triage_prompt + or "You are an AI triage expert. Validate AI comments.", + tools=["Read", "Grep", "Glob"], + model="inherit", + ), + } + + def _build_orchestrator_prompt(self, context: PRContext) -> str: + """Build full prompt for orchestrator with PR context.""" + # Load orchestrator prompt + base_prompt = self._load_prompt("pr_parallel_orchestrator.md") + if not base_prompt: + base_prompt = "You are a PR reviewer. Analyze and delegate to specialists." + + # Build file list + files_list = [] + for file in context.changed_files: + files_list.append( + f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}" + ) + + # Build composite diff + patches = [] + MAX_DIFF_CHARS = 200_000 + + for file in context.changed_files: + if file.patch: + patches.append(f"\n### File: {file.path}\n{file.patch}") + + diff_content = "\n".join(patches) + + if len(diff_content) > MAX_DIFF_CHARS: + diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)" + + # Build AI comments context if present + ai_comments_section = "" + if context.ai_bot_comments: + ai_comments_list = [] + for comment in context.ai_bot_comments[:20]: + ai_comments_list.append( + f"- **{comment.tool_name}** on {comment.file or 'general'}: " + f"{comment.body[:200]}..." + ) + ai_comments_section = f""" +### AI Review Comments (need triage) +Found {len(context.ai_bot_comments)} comments from AI tools: +{chr(10).join(ai_comments_list)} +""" + + pr_context = f""" +--- + +## PR Context for Review + +**PR Number:** {context.pr_number} +**Title:** {context.title} +**Author:** {context.author} +**Base:** {context.base_branch} ← **Head:** {context.head_branch} +**Files Changed:** {len(context.changed_files)} files +**Total Changes:** +{context.total_additions}/-{context.total_deletions} lines + +### Description +{context.description} + +### All Changed Files +{chr(10).join(files_list)} +{ai_comments_section} +### Code Changes +```diff +{diff_content} +``` + +--- + +Now analyze this PR and delegate to the appropriate specialist agents. +Remember: YOU decide which agents to invoke based on YOUR analysis. +The SDK will run invoked agents in parallel automatically. +""" + + return base_prompt + pr_context + + def _create_sdk_client( + self, project_root: Path, model: str, thinking_budget: int | None + ): + """Create SDK client with subagents and configuration. + + Args: + project_root: Root directory of the project + model: Model to use for orchestrator + thinking_budget: Max thinking tokens budget + + Returns: + Configured SDK client instance + """ + return create_client( + project_dir=project_root, + spec_dir=self.github_dir, + model=model, + agent_type="pr_orchestrator_parallel", + max_thinking_tokens=thinking_budget, + agents=self._define_specialist_agents(), + output_format={ + "type": "json_schema", + "schema": ParallelOrchestratorResponse.model_json_schema(), + }, + ) + + def _extract_structured_output( + self, structured_output: dict[str, Any] | None, result_text: str + ) -> tuple[list[PRReviewFinding], list[str]]: + """Parse and extract findings from structured output or text fallback. + + Args: + structured_output: Structured JSON output from agent + result_text: Raw text output as fallback + + Returns: + Tuple of (findings list, agents_invoked list) + """ + agents_from_structured: list[str] = [] + + if structured_output: + findings, agents_from_structured = self._parse_structured_output( + structured_output + ) + if findings is None and result_text: + findings = self._parse_text_output(result_text) + elif findings is None: + findings = [] + else: + findings = self._parse_text_output(result_text) + + return findings, agents_from_structured + + def _log_agents_invoked(self, agents: list[str]) -> None: + """Log invoked agents with clear formatting. + + Args: + agents: List of agent names that were invoked + """ + if agents: + print( + f"[ParallelOrchestrator] Specialist agents invoked: {', '.join(agents)}", + flush=True, + ) + for agent in agents: + print(f"[Agent:{agent}] Analysis complete", flush=True) + + def _log_findings_summary(self, findings: list[PRReviewFinding]) -> None: + """Log findings summary for verification. + + Args: + findings: List of findings to summarize + """ + if findings: + print( + f"[ParallelOrchestrator] Parsed {len(findings)} findings from structured output", + flush=True, + ) + print("[ParallelOrchestrator] Findings summary:", flush=True) + for i, f in enumerate(findings, 1): + print( + f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line})", + flush=True, + ) + + def _create_finding_from_structured(self, finding_data: Any) -> PRReviewFinding: + """Create a PRReviewFinding from structured output data. + + Args: + finding_data: Finding data from structured output + + Returns: + PRReviewFinding instance + """ + finding_id = hashlib.md5( + f"{finding_data.file}:{finding_data.line}:{finding_data.title}".encode(), + usedforsecurity=False, + ).hexdigest()[:12] + + category = map_category(finding_data.category) + + try: + severity = ReviewSeverity(finding_data.severity.lower()) + except ValueError: + severity = ReviewSeverity.MEDIUM + + return PRReviewFinding( + id=finding_id, + file=finding_data.file, + line=finding_data.line, + title=finding_data.title, + description=finding_data.description, + category=category, + severity=severity, + suggested_fix=finding_data.suggested_fix or "", + confidence=self._normalize_confidence(finding_data.confidence), + ) + + async def review(self, context: PRContext) -> PRReviewResult: + """ + Main review entry point. + + Args: + context: Full PR context with all files and patches + + Returns: + PRReviewResult with findings and verdict + """ + logger.info( + f"[ParallelOrchestrator] Starting review for PR #{context.pr_number}" + ) + + try: + self._report_progress( + "orchestrating", + 35, + "Parallel orchestrator analyzing PR...", + pr_number=context.pr_number, + ) + + # Build orchestrator prompt + prompt = self._build_orchestrator_prompt(context) + + # Get project root + project_root = ( + self.project_dir.parent.parent + if self.project_dir.name == "backend" + else self.project_dir + ) + + # Use model and thinking level from config (user settings) + model = self.config.model or "claude-sonnet-4-5-20250929" + thinking_level = self.config.thinking_level or "medium" + thinking_budget = get_thinking_budget(thinking_level) + + logger.info( + f"[ParallelOrchestrator] Using model={model}, " + f"thinking_level={thinking_level}, thinking_budget={thinking_budget}" + ) + + # Create client with subagents defined + # SDK handles parallel execution when Claude invokes multiple Task tools + client = self._create_sdk_client(project_root, model, thinking_budget) + + self._report_progress( + "orchestrating", + 40, + "Orchestrator delegating to specialist agents...", + pr_number=context.pr_number, + ) + + # Run orchestrator session using shared SDK stream processor + async with client: + await client.query(prompt) + + print( + f"[ParallelOrchestrator] Running orchestrator ({model})...", + flush=True, + ) + + # Process SDK stream with shared utility + stream_result = await process_sdk_stream( + client=client, + context_name="ParallelOrchestrator", + ) + + # Check for stream processing errors + if stream_result.get("error"): + logger.error( + f"[ParallelOrchestrator] SDK stream failed: {stream_result['error']}" + ) + raise RuntimeError( + f"SDK stream processing failed: {stream_result['error']}" + ) + + result_text = stream_result["result_text"] + structured_output = stream_result["structured_output"] + agents_invoked = stream_result["agents_invoked"] + msg_count = stream_result["msg_count"] + + self._report_progress( + "finalizing", + 50, + "Synthesizing findings...", + pr_number=context.pr_number, + ) + + # Parse findings from output (structured output also returns agents) + findings, agents_from_structured = self._extract_structured_output( + structured_output, result_text + ) + + # Use agents from structured output (more reliable than streaming detection) + final_agents = ( + agents_from_structured if agents_from_structured else agents_invoked + ) + logger.info( + f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}" + ) + print( + f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}", + flush=True, + ) + + # Deduplicate findings + unique_findings = self._deduplicate_findings(findings) + + logger.info( + f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings" + ) + + # Generate verdict + verdict, verdict_reasoning, blockers = self._generate_verdict( + unique_findings + ) + + # Generate summary + summary = self._generate_summary( + verdict=verdict, + verdict_reasoning=verdict_reasoning, + blockers=blockers, + findings=unique_findings, + agents_invoked=final_agents, + ) + + # Map verdict to overall_status + if verdict == MergeVerdict.BLOCKED: + overall_status = "request_changes" + elif verdict == MergeVerdict.NEEDS_REVISION: + overall_status = "request_changes" + elif verdict == MergeVerdict.MERGE_WITH_CHANGES: + overall_status = "comment" + else: + overall_status = "approve" + + # Extract HEAD SHA from commits for follow-up review tracking + head_sha = None + if context.commits: + latest_commit = context.commits[-1] + head_sha = latest_commit.get("oid") or latest_commit.get("sha") + + result = PRReviewResult( + pr_number=context.pr_number, + repo=self.config.repo, + success=True, + findings=unique_findings, + summary=summary, + overall_status=overall_status, + verdict=verdict, + verdict_reasoning=verdict_reasoning, + blockers=blockers, + reviewed_commit_sha=head_sha, + ) + + self._report_progress( + "analyzed", + 60, + "Parallel analysis complete", + pr_number=context.pr_number, + ) + + return result + + except Exception as e: + logger.error(f"[ParallelOrchestrator] Review failed: {e}", exc_info=True) + return PRReviewResult( + pr_number=context.pr_number, + repo=self.config.repo, + success=False, + error=str(e), + ) + + def _parse_structured_output( + self, structured_output: dict[str, Any] + ) -> tuple[list[PRReviewFinding] | None, list[str]]: + """Parse findings and agents from SDK structured output. + + Returns: + Tuple of (findings list or None if parsing failed, agents list) + """ + findings = [] + agents_from_output: list[str] = [] + + try: + result = ParallelOrchestratorResponse.model_validate(structured_output) + agents_from_output = result.agents_invoked or [] + + logger.info( + f"[ParallelOrchestrator] Structured output: verdict={result.verdict}, " + f"{len(result.findings)} findings, agents={agents_from_output}" + ) + + # Log agents invoked with clear formatting + self._log_agents_invoked(agents_from_output) + + # Convert structured findings to PRReviewFinding objects + for f in result.findings: + finding = self._create_finding_from_structured(f) + findings.append(finding) + + # Log findings summary for verification + self._log_findings_summary(findings) + + except Exception as e: + logger.error( + f"[ParallelOrchestrator] Structured output parsing failed: {e}" + ) + return None, agents_from_output + + return findings, agents_from_output + + def _extract_json_from_text(self, output: str) -> dict[str, Any] | None: + """Extract JSON object from text output. + + Args: + output: Text output to parse + + Returns: + Parsed JSON dict or None if not found + """ + import json + import re + + # Try to find JSON in code blocks + code_block_pattern = r"```(?:json)?\s*(\{[\s\S]*?\})\s*```" + code_block_match = re.search(code_block_pattern, output) + + if code_block_match: + json_str = code_block_match.group(1) + return json.loads(json_str) + + # Try to find raw JSON object + start = output.find("{") + if start == -1: + return None + + brace_count = 0 + end = -1 + for i in range(start, len(output)): + if output[i] == "{": + brace_count += 1 + elif output[i] == "}": + brace_count -= 1 + if brace_count == 0: + end = i + break + + if end != -1: + json_str = output[start : end + 1] + return json.loads(json_str) + + return None + + def _create_finding_from_dict(self, f_data: dict[str, Any]) -> PRReviewFinding: + """Create a PRReviewFinding from dictionary data. + + Args: + f_data: Finding data as dictionary + + Returns: + PRReviewFinding instance + """ + finding_id = hashlib.md5( + f"{f_data.get('file', 'unknown')}:{f_data.get('line', 0)}:{f_data.get('title', 'Untitled')}".encode(), + usedforsecurity=False, + ).hexdigest()[:12] + + category = map_category(f_data.get("category", "quality")) + + try: + severity = ReviewSeverity(f_data.get("severity", "medium").lower()) + except ValueError: + severity = ReviewSeverity.MEDIUM + + return PRReviewFinding( + id=finding_id, + file=f_data.get("file", "unknown"), + line=f_data.get("line", 0), + title=f_data.get("title", "Untitled"), + description=f_data.get("description", ""), + category=category, + severity=severity, + suggested_fix=f_data.get("suggested_fix", ""), + confidence=self._normalize_confidence(f_data.get("confidence", 85)), + ) + + def _parse_text_output(self, output: str) -> list[PRReviewFinding]: + """Parse findings from text output (fallback).""" + findings = [] + + try: + # Extract JSON from text + data = self._extract_json_from_text(output) + if not data: + return findings + + # Get findings array from JSON + findings_data = data.get("findings", []) + + # Convert each finding dict to PRReviewFinding + for f_data in findings_data: + finding = self._create_finding_from_dict(f_data) + findings.append(finding) + + except Exception as e: + logger.error(f"[ParallelOrchestrator] Text parsing failed: {e}") + + return findings + + def _normalize_confidence(self, value: int | float) -> float: + """Normalize confidence to 0.0-1.0 range.""" + if value > 1: + return value / 100.0 + return float(value) + + def _deduplicate_findings( + self, findings: list[PRReviewFinding] + ) -> list[PRReviewFinding]: + """Remove duplicate findings.""" + seen = set() + unique = [] + + for f in findings: + key = (f.file, f.line, f.title.lower().strip()) + if key not in seen: + seen.add(key) + unique.append(f) + + return unique + + def _generate_verdict( + self, findings: list[PRReviewFinding] + ) -> tuple[MergeVerdict, str, list[str]]: + """Generate merge verdict based on findings.""" + blockers = [] + + critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL] + high = [f for f in findings if f.severity == ReviewSeverity.HIGH] + medium = [f for f in findings if f.severity == ReviewSeverity.MEDIUM] + low = [f for f in findings if f.severity == ReviewSeverity.LOW] + + for f in critical: + blockers.append(f"Critical: {f.title} ({f.file}:{f.line})") + + if blockers: + verdict = MergeVerdict.BLOCKED + reasoning = f"Blocked by {len(blockers)} critical issue(s)" + elif high or medium: + # High and Medium severity findings block merge + verdict = MergeVerdict.NEEDS_REVISION + total = len(high) + len(medium) + reasoning = f"{total} issue(s) must be addressed ({len(high)} required, {len(medium)} recommended)" + if low: + reasoning += f", {len(low)} suggestions" + elif low: + # Only Low severity suggestions - can merge but consider addressing + verdict = MergeVerdict.MERGE_WITH_CHANGES + reasoning = f"{len(low)} suggestion(s) to consider" + else: + verdict = MergeVerdict.READY_TO_MERGE + reasoning = "No blocking issues found" + + return verdict, reasoning, blockers + + def _generate_summary( + self, + verdict: MergeVerdict, + verdict_reasoning: str, + blockers: list[str], + findings: list[PRReviewFinding], + agents_invoked: list[str], + ) -> str: + """Generate PR review summary.""" + verdict_emoji = { + MergeVerdict.READY_TO_MERGE: "✅", + MergeVerdict.MERGE_WITH_CHANGES: "🟡", + MergeVerdict.NEEDS_REVISION: "🟠", + MergeVerdict.BLOCKED: "🔴", + } + + lines = [ + f"### Merge Verdict: {verdict_emoji.get(verdict, '⚪')} {verdict.value.upper().replace('_', ' ')}", + verdict_reasoning, + "", + ] + + # Agents used + if agents_invoked: + lines.append(f"**Specialist Agents Invoked:** {', '.join(agents_invoked)}") + lines.append("") + + # Blockers + if blockers: + lines.append("### 🚨 Blocking Issues") + for blocker in blockers: + lines.append(f"- {blocker}") + lines.append("") + + # Findings summary + if findings: + by_severity: dict[str, list] = {} + for f in findings: + severity = f.severity.value + if severity not in by_severity: + by_severity[severity] = [] + by_severity[severity].append(f) + + lines.append("### Findings Summary") + for severity in ["critical", "high", "medium", "low"]: + if severity in by_severity: + count = len(by_severity[severity]) + lines.append(f"- **{severity.capitalize()}**: {count} issue(s)") + lines.append("") + + lines.append("---") + lines.append("_Generated by Auto Claude Parallel Orchestrator (SDK Subagents)_") + + return "\n".join(lines) diff --git a/apps/backend/runners/github/services/pr_review_engine.py b/apps/backend/runners/github/services/pr_review_engine.py index 022834c6..24d1fb69 100644 --- a/apps/backend/runners/github/services/pr_review_engine.py +++ b/apps/backend/runners/github/services/pr_review_engine.py @@ -277,19 +277,22 @@ class PRReviewEngine: Returns: Tuple of (findings, structural_issues, ai_triages, quick_scan_summary) """ - # Use orchestrating agent if enabled - if self.config.use_orchestrator_review: - print("[AI] Using orchestrating PR review agent (Opus 4.5)...", flush=True) + # Use parallel orchestrator with SDK subagents if enabled + if self.config.use_parallel_orchestrator: + print( + "[AI] Using parallel orchestrator PR review (SDK subagents)...", + flush=True, + ) self._report_progress( "orchestrating", 10, - "Starting orchestrating review...", + "Starting parallel orchestrator review...", pr_number=context.pr_number, ) - from .orchestrator_reviewer import OrchestratorReviewer + from .parallel_orchestrator_reviewer import ParallelOrchestratorReviewer - orchestrator = OrchestratorReviewer( + orchestrator = ParallelOrchestratorReviewer( project_dir=self.project_dir, github_dir=self.github_dir, config=self.config, @@ -299,22 +302,16 @@ class PRReviewEngine: result = await orchestrator.review(context) print( - f"[PR Review Engine] Orchestrator returned {len(result.findings)} findings", + f"[PR Review Engine] Parallel orchestrator returned {len(result.findings)} findings", flush=True, ) - # Convert PRReviewResult to expected format - # Orchestrator doesn't use structural_issues or ai_triages quick_scan_summary = { "verdict": result.verdict.value if result.verdict else "unknown", "findings_count": len(result.findings), - "strategy": "orchestrating_agent", + "strategy": "parallel_orchestrator", } - print( - f"[PR Review Engine] Returning tuple with {len(result.findings)} findings", - flush=True, - ) return (result.findings, [], [], quick_scan_summary) # Fall back to multi-pass review diff --git a/apps/backend/runners/github/services/prompt_manager.py b/apps/backend/runners/github/services/prompt_manager.py index da1c94ec..a1e71c6a 100644 --- a/apps/backend/runners/github/services/prompt_manager.py +++ b/apps/backend/runners/github/services/prompt_manager.py @@ -268,7 +268,7 @@ Output JSON array of structural issues: ``` """, ReviewPass.AI_COMMENT_TRIAGE: """ -You are triaging comments from other AI code review tools (CodeRabbit, Cursor, Greptile, etc). +You are triaging comments from other AI code review tools (CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc). For each AI comment, determine: - CRITICAL: Genuine issue that must be addressed before merge diff --git a/apps/backend/runners/github/services/pydantic_models.py b/apps/backend/runners/github/services/pydantic_models.py index 0c50bffe..1bfa6383 100644 --- a/apps/backend/runners/github/services/pydantic_models.py +++ b/apps/backend/runners/github/services/pydantic_models.py @@ -342,3 +342,279 @@ class OrchestratorReviewResponse(BaseModel): default_factory=list, description="Issues found during review" ) summary: str = Field(description="Brief summary of the review") + + +# ============================================================================= +# Parallel Orchestrator Review Response (SDK Subagents) +# ============================================================================= + + +class LogicFinding(BaseFinding): + """A logic/correctness finding from the logic review agent.""" + + category: Literal["logic"] = Field( + default="logic", description="Always 'logic' for logic findings" + ) + confidence: float = Field( + 0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)" + ) + example_input: str | None = Field( + None, description="Concrete input that triggers the bug" + ) + actual_output: str | None = Field(None, description="What the buggy code produces") + expected_output: str | None = Field( + None, description="What the code should produce" + ) + + @field_validator("confidence", mode="before") + @classmethod + def normalize_confidence(cls, v: int | float) -> float: + """Normalize confidence to 0.0-1.0 range.""" + if v > 1: + return v / 100.0 + return float(v) + + +class CodebaseFitFinding(BaseFinding): + """A codebase fit finding from the codebase fit review agent.""" + + category: Literal["codebase_fit"] = Field( + default="codebase_fit", description="Always 'codebase_fit' for fit findings" + ) + confidence: float = Field( + 0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)" + ) + existing_code: str | None = Field( + None, description="Reference to existing code that should be used instead" + ) + codebase_pattern: str | None = Field( + None, description="Description of the established pattern being violated" + ) + + @field_validator("confidence", mode="before") + @classmethod + def normalize_confidence(cls, v: int | float) -> float: + """Normalize confidence to 0.0-1.0 range.""" + if v > 1: + return v / 100.0 + return float(v) + + +class ParallelOrchestratorFinding(BaseModel): + """A finding from the parallel orchestrator with source agent tracking.""" + + id: str = Field(description="Unique identifier for this finding") + file: str = Field(description="File path where issue was found") + line: int = Field(0, description="Line number of the issue") + end_line: int | None = Field(None, description="End line for multi-line issues") + title: str = Field(description="Brief issue title (max 80 chars)") + description: str = Field(description="Detailed explanation of the issue") + category: Literal[ + "security", + "quality", + "logic", + "codebase_fit", + "test", + "docs", + "redundancy", + "pattern", + "performance", + ] = Field(description="Issue category") + severity: Literal["critical", "high", "medium", "low"] = Field( + description="Issue severity level" + ) + confidence: float = Field( + 0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)" + ) + suggested_fix: str | None = Field(None, description="How to fix this issue") + fixable: bool = Field(False, description="Whether this can be auto-fixed") + source_agents: list[str] = Field( + default_factory=list, + description="Which agents reported this finding", + ) + cross_validated: bool = Field( + False, description="Whether multiple agents agreed on this finding" + ) + + @field_validator("confidence", mode="before") + @classmethod + def normalize_confidence(cls, v: int | float) -> float: + """Normalize confidence to 0.0-1.0 range.""" + if v > 1: + return v / 100.0 + return float(v) + + +class AgentAgreement(BaseModel): + """Tracks agreement between agents on findings.""" + + agreed_findings: list[str] = Field( + default_factory=list, + description="Finding IDs that multiple agents agreed on", + ) + conflicting_findings: list[str] = Field( + default_factory=list, + description="Finding IDs where agents disagreed", + ) + resolution_notes: str | None = Field( + None, description="Notes on how conflicts were resolved" + ) + + +class ParallelOrchestratorResponse(BaseModel): + """Complete response schema for parallel orchestrator PR review.""" + + analysis_summary: str = Field( + description="Brief summary of what was analyzed and why agents were chosen" + ) + agents_invoked: list[str] = Field( + default_factory=list, + description="List of agent names that were invoked", + ) + findings: list[ParallelOrchestratorFinding] = Field( + default_factory=list, description="All findings from synthesis" + ) + agent_agreement: AgentAgreement = Field( + default_factory=AgentAgreement, + description="Information about agent agreement on findings", + ) + verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field( + description="Overall PR verdict" + ) + verdict_reasoning: str = Field(description="Explanation for the verdict") + + +# ============================================================================= +# Parallel Follow-up Review Response (SDK Subagents for Follow-up) +# ============================================================================= + + +class ResolutionVerification(BaseModel): + """AI-verified resolution status for a previous finding.""" + + finding_id: str = Field(description="ID of the previous finding") + status: Literal["resolved", "partially_resolved", "unresolved", "cant_verify"] = ( + Field(description="Resolution status after AI verification") + ) + confidence: float = Field( + 0.85, ge=0.0, le=1.0, description="Confidence in the resolution status" + ) + evidence: str = Field(description="What evidence supports this resolution status") + resolution_notes: str | None = Field( + None, description="Detailed notes on how the issue was addressed" + ) + + @field_validator("confidence", mode="before") + @classmethod + def normalize_confidence(cls, v: int | float) -> float: + """Normalize confidence to 0.0-1.0 range.""" + if v > 1: + return v / 100.0 + return float(v) + + +class ParallelFollowupFinding(BaseModel): + """A finding from parallel follow-up review with source agent tracking.""" + + id: str = Field(description="Unique identifier for this finding") + file: str = Field(description="File path where issue was found") + line: int = Field(0, description="Line number of the issue") + end_line: int | None = Field(None, description="End line for multi-line issues") + title: str = Field(description="Brief issue title (max 80 chars)") + description: str = Field(description="Detailed explanation of the issue") + category: Literal[ + "security", + "quality", + "logic", + "test", + "docs", + "regression", + "incomplete_fix", + ] = Field(description="Issue category") + severity: Literal["critical", "high", "medium", "low"] = Field( + description="Issue severity level" + ) + confidence: float = Field( + 0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)" + ) + suggested_fix: str | None = Field(None, description="How to fix this issue") + fixable: bool = Field(False, description="Whether this can be auto-fixed") + source_agent: str = Field( + description="Which agent reported this finding (resolution/newcode/comment)" + ) + related_to_previous: str | None = Field( + None, description="ID of related previous finding if this is a regression" + ) + + @field_validator("confidence", mode="before") + @classmethod + def normalize_confidence(cls, v: int | float) -> float: + """Normalize confidence to 0.0-1.0 range.""" + if v > 1: + return v / 100.0 + return float(v) + + +class CommentAnalysis(BaseModel): + """Analysis of a contributor or AI comment.""" + + comment_id: str = Field(description="Identifier for the comment") + author: str = Field(description="Comment author") + is_ai_bot: bool = Field(description="Whether this is from an AI tool") + requires_response: bool = Field(description="Whether this comment needs a response") + sentiment: Literal["question", "concern", "suggestion", "praise", "neutral"] = ( + Field(description="Comment sentiment/type") + ) + summary: str = Field(description="Brief summary of the comment") + action_needed: str | None = Field(None, description="What action is needed if any") + + +class ParallelFollowupResponse(BaseModel): + """Complete response schema for parallel follow-up PR review.""" + + # Analysis metadata + analysis_summary: str = Field( + description="Brief summary of what was analyzed in this follow-up" + ) + agents_invoked: list[str] = Field( + default_factory=list, + description="List of agent names that were invoked", + ) + commits_analyzed: int = Field(0, description="Number of new commits analyzed") + files_changed: int = Field( + 0, description="Number of files changed since last review" + ) + + # Resolution verification (from resolution-verifier agent) + resolution_verifications: list[ResolutionVerification] = Field( + default_factory=list, + description="AI-verified resolution status for each previous finding", + ) + + # New findings (from new-code-reviewer agent) + new_findings: list[ParallelFollowupFinding] = Field( + default_factory=list, + description="New issues found in changes since last review", + ) + + # Comment analysis (from comment-analyzer agent) + comment_analyses: list[CommentAnalysis] = Field( + default_factory=list, + description="Analysis of contributor and AI comments", + ) + comment_findings: list[ParallelFollowupFinding] = Field( + default_factory=list, + description="Issues identified from comment analysis", + ) + + # Agent agreement tracking + agent_agreement: AgentAgreement = Field( + default_factory=AgentAgreement, + description="Information about agent agreement on findings", + ) + + # Verdict + verdict: Literal[ + "READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED" + ] = Field(description="Overall merge verdict") + verdict_reasoning: str = Field(description="Explanation for the verdict") diff --git a/apps/backend/runners/github/services/review_tools.py b/apps/backend/runners/github/services/review_tools.py index d36b0fd9..881d8353 100644 --- a/apps/backend/runners/github/services/review_tools.py +++ b/apps/backend/runners/github/services/review_tools.py @@ -18,50 +18,20 @@ try: from ...analysis.test_discovery import TestDiscovery from ...core.client import create_client from ..context_gatherer import PRContext - from ..models import PRReviewFinding, ReviewCategory, ReviewSeverity + from ..models import PRReviewFinding, ReviewSeverity + from .category_utils import map_category except (ImportError, ValueError, SystemError): from analysis.test_discovery import TestDiscovery + from category_utils import map_category from context_gatherer import PRContext from core.client import create_client - from models import PRReviewFinding, ReviewCategory, ReviewSeverity + from models import PRReviewFinding, ReviewSeverity logger = logging.getLogger(__name__) -# Map AI-generated category names to valid ReviewCategory enum values -_CATEGORY_MAPPING = { - # Direct matches - "security": ReviewCategory.SECURITY, - "quality": ReviewCategory.QUALITY, - "style": ReviewCategory.STYLE, - "test": ReviewCategory.TEST, - "docs": ReviewCategory.DOCS, - "pattern": ReviewCategory.PATTERN, - "performance": ReviewCategory.PERFORMANCE, - "verification_failed": ReviewCategory.VERIFICATION_FAILED, - "redundancy": ReviewCategory.REDUNDANCY, - # AI-generated alternatives - "correctness": ReviewCategory.QUALITY, - "consistency": ReviewCategory.PATTERN, - "testing": ReviewCategory.TEST, - "documentation": ReviewCategory.DOCS, - "bug": ReviewCategory.QUALITY, - "logic": ReviewCategory.QUALITY, - "error_handling": ReviewCategory.QUALITY, - "maintainability": ReviewCategory.QUALITY, - "readability": ReviewCategory.STYLE, - "best_practices": ReviewCategory.PATTERN, - "architecture": ReviewCategory.PATTERN, - "complexity": ReviewCategory.QUALITY, - "dead_code": ReviewCategory.REDUNDANCY, - "unused": ReviewCategory.REDUNDANCY, -} - - -def _map_category(category_str: str) -> ReviewCategory: - """Map an AI-generated category string to a valid ReviewCategory enum.""" - normalized = category_str.lower().strip().replace("-", "_") - return _CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY) +# Use shared category mapping from category_utils +_map_category = map_category @dataclass diff --git a/apps/backend/runners/github/services/sdk_utils.py b/apps/backend/runners/github/services/sdk_utils.py new file mode 100644 index 00000000..0e6da74f --- /dev/null +++ b/apps/backend/runners/github/services/sdk_utils.py @@ -0,0 +1,348 @@ +""" +SDK Stream Processing Utilities +================================ + +Shared utilities for processing Claude Agent SDK response streams. + +This module extracts common SDK message processing patterns used across +parallel orchestrator and follow-up reviewers. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable +from typing import Any + +logger = logging.getLogger(__name__) + +# Check if debug mode is enabled +DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes") + + +async def process_sdk_stream( + client: Any, + on_thinking: Callable[[str], None] | None = None, + on_tool_use: Callable[[str, str, dict[str, Any]], None] | None = None, + on_tool_result: Callable[[str, bool, Any], None] | None = None, + on_text: Callable[[str], None] | None = None, + on_structured_output: Callable[[dict[str, Any]], None] | None = None, + context_name: str = "SDK", +) -> dict[str, Any]: + """ + Process SDK response stream with customizable callbacks. + + This function handles the common pattern of: + - Tracking thinking blocks + - Tracking tool invocations (especially Task/subagent calls) + - Tracking tool results + - Collecting text output + - Extracting structured output + + Args: + client: Claude SDK client with receive_response() method + on_thinking: Callback for thinking blocks - receives thinking text + on_tool_use: Callback for tool invocations - receives (tool_name, tool_id, tool_input) + on_tool_result: Callback for tool results - receives (tool_id, is_error, result_content) + on_text: Callback for text output - receives text string + on_structured_output: Callback for structured output - receives dict + context_name: Name for logging (e.g., "ParallelOrchestrator", "ParallelFollowup") + + Returns: + Dictionary with: + - result_text: Accumulated text output + - structured_output: Final structured output (if any) + - agents_invoked: List of agent names invoked via Task tool + - msg_count: Total message count + - subagent_tool_ids: Mapping of tool_id -> agent_name + - error: Error message if stream processing failed (None on success) + """ + result_text = "" + structured_output = None + agents_invoked = [] + msg_count = 0 + stream_error = None + # Track subagent tool IDs to log their results + subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name + + print(f"[{context_name}] Processing SDK stream...", flush=True) + if DEBUG_MODE: + print(f"[DEBUG {context_name}] Awaiting response stream...", flush=True) + + try: + async for msg in client.receive_response(): + try: + msg_type = type(msg).__name__ + msg_count += 1 + + if DEBUG_MODE: + # Log every message type for visibility + msg_details = "" + if hasattr(msg, "type"): + msg_details = f" (type={msg.type})" + print( + f"[DEBUG {context_name}] Message #{msg_count}: {msg_type}{msg_details}", + flush=True, + ) + + # Track thinking blocks + if msg_type == "ThinkingBlock" or ( + hasattr(msg, "type") and msg.type == "thinking" + ): + thinking_text = getattr(msg, "thinking", "") or getattr( + msg, "text", "" + ) + if thinking_text: + print( + f"[{context_name}] AI thinking: {len(thinking_text)} chars", + flush=True, + ) + if DEBUG_MODE: + # Show first 200 chars of thinking + preview = thinking_text[:200].replace("\n", " ") + print( + f"[DEBUG {context_name}] Thinking preview: {preview}...", + flush=True, + ) + # Invoke callback + if on_thinking: + on_thinking(thinking_text) + + # Track subagent invocations (Task tool calls) + if msg_type == "ToolUseBlock" or ( + hasattr(msg, "type") and msg.type == "tool_use" + ): + tool_name = getattr(msg, "name", "") + tool_id = getattr(msg, "id", "unknown") + tool_input = getattr(msg, "input", {}) + + if DEBUG_MODE: + print( + f"[DEBUG {context_name}] Tool call: {tool_name} (id={tool_id})", + flush=True, + ) + + if tool_name == "Task": + # Extract which agent was invoked + agent_name = tool_input.get("subagent_type", "unknown") + agents_invoked.append(agent_name) + # Track this tool ID to log its result later + subagent_tool_ids[tool_id] = agent_name + print( + f"[{context_name}] Invoked agent: {agent_name}", flush=True + ) + elif tool_name == "StructuredOutput": + if tool_input: + # Warn if overwriting existing structured output + if structured_output is not None: + logger.warning( + f"[{context_name}] Multiple StructuredOutput blocks received, " + f"overwriting previous output" + ) + structured_output = tool_input + print( + f"[{context_name}] Received structured output", + flush=True, + ) + # Invoke callback + if on_structured_output: + on_structured_output(tool_input) + elif DEBUG_MODE: + # Log other tool calls in debug mode + print( + f"[DEBUG {context_name}] Other tool: {tool_name}", + flush=True, + ) + + # Invoke callback for all tool uses + if on_tool_use: + on_tool_use(tool_name, tool_id, tool_input) + + # Track tool results + if msg_type == "ToolResultBlock" or ( + hasattr(msg, "type") and msg.type == "tool_result" + ): + tool_id = getattr(msg, "tool_use_id", "unknown") + is_error = getattr(msg, "is_error", False) + result_content = getattr(msg, "content", "") + + # Handle list of content blocks + if isinstance(result_content, list): + result_content = " ".join( + str(getattr(c, "text", c)) for c in result_content + ) + + # Check if this is a subagent result + if tool_id in subagent_tool_ids: + agent_name = subagent_tool_ids[tool_id] + status = "ERROR" if is_error else "complete" + result_preview = ( + str(result_content)[:600].replace("\n", " ").strip() + ) + print( + f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}", + flush=True, + ) + elif DEBUG_MODE: + status = "ERROR" if is_error else "OK" + print( + f"[DEBUG {context_name}] Tool result: {tool_id} [{status}]", + flush=True, + ) + + # Invoke callback + if on_tool_result: + on_tool_result(tool_id, is_error, result_content) + + # Collect text output and check for tool uses in content blocks + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + + # Check for tool use blocks within content + if ( + block_type == "ToolUseBlock" + or getattr(block, "type", "") == "tool_use" + ): + tool_name = getattr(block, "name", "") + tool_id = getattr(block, "id", "unknown") + tool_input = getattr(block, "input", {}) + + if tool_name == "Task": + agent_name = tool_input.get("subagent_type", "unknown") + if agent_name not in agents_invoked: + agents_invoked.append(agent_name) + subagent_tool_ids[tool_id] = agent_name + print( + f"[{context_name}] Invoking agent: {agent_name}", + flush=True, + ) + elif tool_name == "StructuredOutput": + if tool_input: + # Warn if overwriting existing structured output + if structured_output is not None: + logger.warning( + f"[{context_name}] Multiple StructuredOutput blocks received, " + f"overwriting previous output" + ) + structured_output = tool_input + # Invoke callback + if on_structured_output: + on_structured_output(tool_input) + + # Invoke callback + if on_tool_use: + on_tool_use(tool_name, tool_id, tool_input) + + # Collect text + if hasattr(block, "text"): + result_text += block.text + # Always print text content preview (not just in DEBUG_MODE) + text_preview = block.text[:500].replace("\n", " ").strip() + if text_preview: + print( + f"[{context_name}] AI response: {text_preview}{'...' if len(block.text) > 500 else ''}", + flush=True, + ) + # Invoke callback + if on_text: + on_text(block.text) + + # Check for StructuredOutput in content (legacy check) + if getattr(block, "name", "") == "StructuredOutput": + structured_data = getattr(block, "input", None) + if structured_data: + # Warn if overwriting existing structured output + if structured_output is not None: + logger.warning( + f"[{context_name}] Multiple StructuredOutput blocks received, " + f"overwriting previous output" + ) + structured_output = structured_data + # Invoke callback + if on_structured_output: + on_structured_output(structured_data) + + # Check for structured_output attribute + if hasattr(msg, "structured_output") and msg.structured_output: + # Warn if overwriting existing structured output + if structured_output is not None: + logger.warning( + f"[{context_name}] Multiple StructuredOutput blocks received, " + f"overwriting previous output" + ) + structured_output = msg.structured_output + # Invoke callback + if on_structured_output: + on_structured_output(msg.structured_output) + + # Check for tool results in UserMessage (subagent results come back here) + if msg_type == "UserMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + # Check for tool result blocks + if ( + block_type == "ToolResultBlock" + or getattr(block, "type", "") == "tool_result" + ): + tool_id = getattr(block, "tool_use_id", "unknown") + is_error = getattr(block, "is_error", False) + result_content = getattr(block, "content", "") + + # Handle list of content blocks + if isinstance(result_content, list): + result_content = " ".join( + str(getattr(c, "text", c)) for c in result_content + ) + + # Check if this is a subagent result + if tool_id in subagent_tool_ids: + agent_name = subagent_tool_ids[tool_id] + status = "ERROR" if is_error else "complete" + result_preview = ( + str(result_content)[:600].replace("\n", " ").strip() + ) + print( + f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}", + flush=True, + ) + + # Invoke callback + if on_tool_result: + on_tool_result(tool_id, is_error, result_content) + + except (AttributeError, TypeError, KeyError) as msg_error: + # Log individual message processing errors but continue + logger.warning( + f"[{context_name}] Error processing message #{msg_count}: {msg_error}" + ) + if DEBUG_MODE: + print( + f"[DEBUG {context_name}] Message processing error: {msg_error}", + flush=True, + ) + # Continue processing subsequent messages + + except Exception as e: + # Log stream-level errors + stream_error = str(e) + logger.error(f"[{context_name}] SDK stream processing failed: {e}") + print(f"[{context_name}] ERROR: Stream processing failed: {e}", flush=True) + + if DEBUG_MODE: + print( + f"[DEBUG {context_name}] Session ended. Total messages: {msg_count}", + flush=True, + ) + + print(f"[{context_name}] Session ended. Total messages: {msg_count}", flush=True) + + return { + "result_text": result_text, + "structured_output": structured_output, + "agents_invoked": agents_invoked, + "msg_count": msg_count, + "subagent_tool_ids": subagent_tool_ids, + "error": stream_error, + } diff --git a/apps/backend/runners/github/test_bot_detection.py b/apps/backend/runners/github/test_bot_detection.py index 7a244e59..b512fb2d 100644 --- a/apps/backend/runners/github/test_bot_detection.py +++ b/apps/backend/runners/github/test_bot_detection.py @@ -6,11 +6,18 @@ Tests the BotDetector class to ensure it correctly prevents infinite loops. """ import json +import sys from datetime import datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch import pytest + +# Use direct file import to avoid package import issues +_github_dir = Path(__file__).parent +if str(_github_dir) not in sys.path: + sys.path.insert(0, str(_github_dir)) + from bot_detection import BotDetectionState, BotDetector @@ -125,13 +132,15 @@ class TestBotDetection: def test_get_last_commit_sha(self, mock_bot_detector): """Test extracting last commit SHA.""" + # GitHub API returns commits in chronological order (oldest first, newest last) + # So commits[-1] is the LATEST commit commits = [ - {"oid": "abc123"}, - {"oid": "def456"}, + {"oid": "abc123"}, # Oldest commit + {"oid": "def456"}, # Latest commit ] sha = mock_bot_detector.get_last_commit_sha(commits) - assert sha == "abc123" + assert sha == "def456" # Should return the LAST (latest) commit # Test with sha field instead of oid commits_with_sha = [{"sha": "xyz789"}] @@ -143,13 +152,16 @@ class TestBotDetection: class TestCoolingOff: - """Test cooling off period.""" + """Test cooling off period. + + Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs. + """ def test_within_cooling_off(self, mock_bot_detector): """Test PR within cooling off period.""" - # Set last review to 5 minutes ago - five_min_ago = datetime.now() - timedelta(minutes=5) - mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat() + # Set last review to 30 seconds ago (within 1 minute cooling off) + half_min_ago = datetime.now() - timedelta(seconds=30) + mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat() is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) @@ -158,9 +170,9 @@ class TestCoolingOff: def test_outside_cooling_off(self, mock_bot_detector): """Test PR outside cooling off period.""" - # Set last review to 15 minutes ago - fifteen_min_ago = datetime.now() - timedelta(minutes=15) - mock_bot_detector.state.last_review_times["123"] = fifteen_min_ago.isoformat() + # Set last review to 2 minutes ago (outside 1 minute cooling off) + two_min_ago = datetime.now() - timedelta(minutes=2) + mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat() is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) @@ -229,11 +241,16 @@ class TestShouldSkipReview: assert "bot user" in reason def test_skip_bot_commit(self, mock_bot_detector): - """Test skipping PR with bot commit.""" + """Test skipping PR with bot commit as the latest commit.""" pr_data = {"author": {"login": "alice"}} + # GitHub API returns commits in chronological order (oldest first, newest last) + # So commits[-1] is the LATEST commit - which is the bot commit commits = [ - {"author": {"login": "test-bot"}, "oid": "abc123"}, # Latest is bot - {"author": {"login": "alice"}, "oid": "def456"}, + {"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice) + { + "author": {"login": "test-bot"}, + "oid": "def456", + }, # Latest commit (by bot) ] should_skip, reason = mock_bot_detector.should_skip_pr_review( @@ -247,9 +264,9 @@ class TestShouldSkipReview: def test_skip_cooling_off(self, mock_bot_detector): """Test skipping during cooling off period.""" - # Set last review to 5 minutes ago - five_min_ago = datetime.now() - timedelta(minutes=5) - mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat() + # Set last review to 30 seconds ago (within 1 minute cooling off) + half_min_ago = datetime.now() - timedelta(seconds=30) + mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat() pr_data = {"author": {"login": "alice"}} commits = [{"author": {"login": "alice"}, "oid": "abc123"}] @@ -349,7 +366,7 @@ class TestStateManagement: assert stats["review_own_prs"] is False assert stats["total_prs_tracked"] == 2 assert stats["total_reviews_performed"] == 3 - assert stats["cooling_off_minutes"] == 10 + assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing class TestEdgeCases: diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 97bda6b2..6b22c983 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", @@ -50,6 +51,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", + "semver": "^7.7.3", "tailwind-merge": "^3.4.0", "uuid": "^13.0.0", "zod": "^4.2.1", @@ -66,6 +68,7 @@ "@types/node": "^25.0.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/semver": "^7.7.1", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.22", @@ -198,7 +201,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -583,7 +585,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -627,7 +628,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -667,7 +667,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1074,6 +1073,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1095,6 +1095,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2730,6 +2731,61 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -4224,7 +4280,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4411,7 +4468,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4422,7 +4478,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4437,6 +4492,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4514,7 +4576,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4927,7 +4988,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4988,7 +5048,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5161,6 +5220,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5555,7 +5615,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6226,7 +6285,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -6594,7 +6654,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -6652,7 +6711,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "16.6.1", @@ -6728,7 +6788,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -6866,6 +6925,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6886,6 +6946,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6901,6 +6962,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6911,6 +6973,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -7280,7 +7343,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8526,7 +8588,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -9317,7 +9378,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10249,6 +10309,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -12439,7 +12500,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12537,7 +12597,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12574,6 +12633,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -12591,6 +12651,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12611,6 +12672,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12626,6 +12688,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -12638,7 +12701,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/proc-log": { "version": "2.0.1", @@ -12742,7 +12806,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12752,7 +12815,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14072,8 +14134,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -14130,6 +14191,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14156,6 +14218,7 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -14177,6 +14240,7 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -14190,6 +14254,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -14204,6 +14269,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14520,7 +14586,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14870,7 +14935,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15912,7 +15976,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6424f361..53e82ed1 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -57,6 +57,7 @@ "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", @@ -88,6 +89,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", + "semver": "^7.7.3", "tailwind-merge": "^3.4.0", "uuid": "^13.0.0", "zod": "^4.2.1", @@ -104,6 +106,7 @@ "@types/node": "^25.0.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/semver": "^7.7.1", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.22", diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index 05174199..b51eb34e 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -66,6 +66,7 @@ const COMMON_BIN_PATHS: Record = { '/usr/local/bin', // Intel Homebrew / system '/opt/homebrew/sbin', // Apple Silicon Homebrew sbin '/usr/local/sbin', // Intel Homebrew sbin + '~/.local/bin', // User-local binaries (Claude CLI) ], linux: [ '/usr/local/bin', diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index 84232f24..59235734 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -61,6 +61,13 @@ export function registerAgenteventsHandlers( agentManager.on('exit', (taskId: string, code: number | null, processType: ProcessType) => { const mainWindow = getMainWindow(); if (mainWindow) { + // Send final plan state to renderer BEFORE unwatching + // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) + const finalPlan = fileWatcher.getCurrentPlan(taskId); + if (finalPlan) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan); + } + fileWatcher.unwatch(taskId); if (processType === 'spec-creation') { diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts new file mode 100644 index 00000000..32ada422 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -0,0 +1,476 @@ +/** + * Claude Code CLI Handlers + * + * IPC handlers for Claude Code CLI version checking and installation. + * Provides functionality to: + * - Check installed vs latest version + * - Open terminal with installation command + */ + +import { ipcMain, shell } from 'electron'; +import { execFileSync, spawn } from 'child_process'; +import { existsSync, statSync } from 'fs'; +import path from 'path'; +import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import type { IPCResult } from '../../shared/types'; +import type { ClaudeCodeVersionInfo } from '../../shared/types/cli'; +import { getToolInfo } from '../cli-tool-manager'; +import { readSettingsFile } from '../settings-utils'; +import semver from 'semver'; + +// Cache for latest version (avoid hammering npm registry) +let cachedLatestVersion: { version: string; timestamp: number } | null = null; +const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Fetch the latest version of Claude Code from npm registry + */ +async function fetchLatestVersion(): Promise { + // Check cache first + if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) { + return cachedLatestVersion.version; + } + + try { + const response = await fetch('https://registry.npmjs.org/@anthropic-ai/claude-code/latest', { + headers: { + 'Accept': 'application/json', + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const version = data.version; + + if (!version || typeof version !== 'string') { + throw new Error('Invalid version format from npm registry'); + } + + // Cache the result + cachedLatestVersion = { version, timestamp: Date.now() }; + return version; + } catch (error) { + console.error('[Claude Code] Failed to fetch latest version:', error); + // Return cached version if available, even if expired + if (cachedLatestVersion) { + return cachedLatestVersion.version; + } + throw error; + } +} + +/** + * Get the platform-specific install command for Claude Code + * @param isUpdate - If true, Claude is already installed and we just need to update + */ +function getInstallCommand(isUpdate: boolean): string { + if (process.platform === 'win32') { + if (isUpdate) { + // Update: kill running Claude processes first, then update with --force + return 'taskkill /IM claude.exe /F 2>nul; claude install --force latest'; + } + return 'irm https://claude.ai/install.ps1 | iex'; + } else { + if (isUpdate) { + // Update: kill running Claude processes first, then update with --force + // pkill sends SIGTERM to gracefully stop Claude processes + return 'pkill -x claude 2>/dev/null; sleep 1; claude install --force latest'; + } + // Fresh install: use the full install script + return 'curl -fsSL https://claude.ai/install.sh | bash -s -- latest'; + } +} + +/** + * Escape single quotes in a string for use in AppleScript + */ +export function escapeAppleScriptString(str: string): string { + return str.replace(/'/g, "'\\''"); +} + +/** + * Open a terminal with the given command + * Uses the user's preferred terminal from settings + * Supports macOS, Windows, and Linux terminals + */ +export async function openTerminalWithCommand(command: string): Promise { + const platform = process.platform; + const settings = readSettingsFile(); + const preferredTerminal = settings?.preferredTerminal as string | undefined; + + console.log('[Claude Code] Platform:', platform); + console.log('[Claude Code] Preferred terminal:', preferredTerminal); + + if (platform === 'darwin') { + // macOS: Use AppleScript to open terminal with command + const escapedCommand = escapeAppleScriptString(command); + let script: string; + + // Map SupportedTerminal values to terminal handling + // Values come from settings.preferredTerminal (SupportedTerminal type) + const terminalId = preferredTerminal?.toLowerCase() || 'terminal'; + + console.log('[Claude Code] Using terminal:', terminalId); + + if (terminalId === 'iterm2') { + // iTerm2 + script = ` + tell application "iTerm" + activate + create window with default profile + tell current session of current window + write text "${escapedCommand}" + end tell + end tell + `; + } else if (terminalId === 'warp') { + // Warp - open and send command + script = ` + tell application "Warp" + activate + end tell + delay 0.5 + tell application "System Events" + keystroke "${escapedCommand}" + keystroke return + end tell + `; + } else if (terminalId === 'kitty') { + // Kitty - use command line + spawn('kitty', ['--', 'bash', '-c', command], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'alacritty') { + // Alacritty - use command line + spawn('open', ['-a', 'Alacritty', '--args', '-e', 'bash', '-c', command], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'wezterm') { + // WezTerm - use command line + spawn('wezterm', ['start', '--', 'bash', '-c', command], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'ghostty') { + // Ghostty + script = ` + tell application "Ghostty" + activate + end tell + delay 0.3 + tell application "System Events" + keystroke "${escapedCommand}" + keystroke return + end tell + `; + } else if (terminalId === 'hyper') { + // Hyper + script = ` + tell application "Hyper" + activate + end tell + delay 0.3 + tell application "System Events" + keystroke "${escapedCommand}" + keystroke return + end tell + `; + } else if (terminalId === 'tabby') { + // Tabby (formerly Terminus) + script = ` + tell application "Tabby" + activate + end tell + delay 0.3 + tell application "System Events" + keystroke "${escapedCommand}" + keystroke return + end tell + `; + } else { + // Default: Terminal.app (handles 'terminal', 'system', or any unknown value) + // IMPORTANT: do script FIRST, then activate - this prevents opening a blank default window + // when Terminal.app isn't already running + script = ` + tell application "Terminal" + do script "${escapedCommand}" + activate + end tell + `; + } + + console.log('[Claude Code] Running AppleScript...'); + execFileSync('osascript', ['-e', script], { stdio: 'pipe' }); + + } else if (platform === 'win32') { + // Windows: Use appropriate terminal + // Values match SupportedTerminal type: 'windowsterminal', 'powershell', 'cmd', 'conemu', 'cmder', 'gitbash' + const terminalId = preferredTerminal?.toLowerCase() || 'powershell'; + + console.log('[Claude Code] Using terminal:', terminalId); + + if (terminalId === 'windowsterminal') { + // Windows Terminal + spawn('wt.exe', ['powershell.exe', '-NoExit', '-Command', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } else if (terminalId === 'cmd') { + // Command Prompt + spawn('cmd.exe', ['/K', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } else if (terminalId === 'conemu') { + // ConEmu + spawn('ConEmu64.exe', ['-run', 'powershell.exe', '-NoExit', '-Command', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } else if (terminalId === 'cmder') { + // Cmder (ConEmu-based) + spawn('cmder.exe', ['/TASK', 'powershell', '/CMD', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } else if (terminalId === 'gitbash') { + // Git Bash + spawn('C:\\Program Files\\Git\\git-bash.exe', ['-c', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } else if (terminalId === 'hyper') { + // Hyper + spawn('hyper.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref(); + // Note: Hyper doesn't support direct command execution, user needs to paste + } else if (terminalId === 'tabby') { + // Tabby + spawn('tabby.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref(); + } else if (terminalId === 'alacritty') { + // Alacritty on Windows + spawn('alacritty.exe', ['-e', 'powershell.exe', '-NoExit', '-Command', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } else if (terminalId === 'wezterm') { + // WezTerm on Windows + spawn('wezterm.exe', ['start', '--', 'powershell.exe', '-NoExit', '-Command', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } else { + // Default: PowerShell (handles 'powershell', 'system', or any unknown value) + spawn('powershell.exe', ['-NoExit', '-Command', command], { + detached: true, stdio: 'ignore', shell: false, + }).unref(); + } + } else { + // Linux: Use preferred terminal or try common emulators + // Values match SupportedTerminal type: 'gnometerminal', 'konsole', 'xfce4terminal', 'tilix', etc. + const terminalId = preferredTerminal?.toLowerCase() || ''; + + console.log('[Claude Code] Using terminal:', terminalId || 'auto-detect'); + + // Command to run (keep terminal open after execution) + const bashCommand = `${command}; exec bash`; + + // Try to use preferred terminal if specified + if (terminalId === 'gnometerminal') { + spawn('gnome-terminal', ['--', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'konsole') { + spawn('konsole', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'xfce4terminal') { + spawn('xfce4-terminal', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'lxterminal') { + spawn('lxterminal', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'mate-terminal') { + spawn('mate-terminal', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'tilix') { + spawn('tilix', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'terminator') { + spawn('terminator', ['-e', `bash -c "${bashCommand}"`], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'guake') { + spawn('guake', ['-e', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'yakuake') { + spawn('yakuake', ['-e', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'kitty') { + spawn('kitty', ['--', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'alacritty') { + spawn('alacritty', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'wezterm') { + spawn('wezterm', ['start', '--', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'hyper') { + spawn('hyper', [], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'tabby') { + spawn('tabby', [], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'xterm') { + spawn('xterm', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'urxvt') { + spawn('urxvt', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'st') { + spawn('st', ['-e', 'bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } else if (terminalId === 'foot') { + spawn('foot', ['bash', '-c', bashCommand], { detached: true, stdio: 'ignore' }).unref(); + return; + } + + // Auto-detect (for 'system' or no preference): try common terminal emulators in order + const terminals: Array<{ cmd: string; args: string[] }> = [ + { cmd: 'gnome-terminal', args: ['--', 'bash', '-c', bashCommand] }, + { cmd: 'konsole', args: ['-e', 'bash', '-c', bashCommand] }, + { cmd: 'xfce4-terminal', args: ['-e', `bash -c "${bashCommand}"`] }, + { cmd: 'tilix', args: ['-e', 'bash', '-c', bashCommand] }, + { cmd: 'terminator', args: ['-e', `bash -c "${bashCommand}"`] }, + { cmd: 'kitty', args: ['--', 'bash', '-c', bashCommand] }, + { cmd: 'alacritty', args: ['-e', 'bash', '-c', bashCommand] }, + { cmd: 'xterm', args: ['-e', 'bash', '-c', bashCommand] }, + ]; + + let opened = false; + for (const { cmd, args } of terminals) { + try { + spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); + opened = true; + console.log('[Claude Code] Opened terminal:', cmd); + break; + } catch { + continue; + } + } + + if (!opened) { + throw new Error('No supported terminal emulator found'); + } + } +} + +/** + * Register Claude Code IPC handlers + */ +export function registerClaudeCodeHandlers(): void { + // Check Claude Code version + ipcMain.handle( + IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION, + async (): Promise> => { + try { + console.log('[Claude Code] Checking version...'); + + // Get installed version via cli-tool-manager + let detectionResult; + try { + detectionResult = getToolInfo('claude'); + console.log('[Claude Code] Detection result:', JSON.stringify(detectionResult, null, 2)); + } catch (detectionError) { + console.error('[Claude Code] Detection error:', detectionError); + throw new Error(`Detection failed: ${detectionError instanceof Error ? detectionError.message : 'Unknown error'}`); + } + + const installed = detectionResult.found ? detectionResult.version || null : null; + console.log('[Claude Code] Installed version:', installed); + + // Fetch latest version from npm + let latest: string; + try { + console.log('[Claude Code] Fetching latest version from npm...'); + latest = await fetchLatestVersion(); + console.log('[Claude Code] Latest version:', latest); + } catch (error) { + console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error); + // If we can't fetch latest, still return installed info + return { + success: true, + data: { + installed, + latest: 'unknown', + isOutdated: false, + path: detectionResult.path, + detectionResult, + }, + }; + } + + // Compare versions + let isOutdated = false; + if (installed && latest !== 'unknown') { + try { + // Clean version strings (remove 'v' prefix if present) + const cleanInstalled = installed.replace(/^v/, ''); + const cleanLatest = latest.replace(/^v/, ''); + isOutdated = semver.lt(cleanInstalled, cleanLatest); + } catch { + // If semver comparison fails, assume not outdated + isOutdated = false; + } + } + + console.log('[Claude Code] Check complete:', { installed, latest, isOutdated }); + return { + success: true, + data: { + installed, + latest, + isOutdated, + path: detectionResult.path, + detectionResult, + }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Check failed:', errorMsg, error); + return { + success: false, + error: `Failed to check Claude Code version: ${errorMsg}`, + }; + } + } + ); + + // Install Claude Code (open terminal with install command) + ipcMain.handle( + IPC_CHANNELS.CLAUDE_CODE_INSTALL, + async (): Promise> => { + try { + // Check if Claude is already installed to determine if this is an update + let isUpdate = false; + try { + const detectionResult = getToolInfo('claude'); + isUpdate = detectionResult.found && !!detectionResult.version; + console.log('[Claude Code] Is update:', isUpdate, 'detected version:', detectionResult.version); + } catch { + // Detection failed, assume fresh install + isUpdate = false; + } + + const command = getInstallCommand(isUpdate); + console.log('[Claude Code] Install command:', command); + console.log('[Claude Code] Opening terminal...'); + await openTerminalWithCommand(command); + console.log('[Claude Code] Terminal opened successfully'); + + return { + success: true, + data: { command }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Install failed:', errorMsg, error); + return { + success: false, + error: `Failed to open terminal for installation: ${errorMsg}`, + }; + } + } + ); + + console.warn('[IPC] Claude Code handlers registered'); +} diff --git a/apps/frontend/src/main/ipc-handlers/env-handlers.ts b/apps/frontend/src/main/ipc-handlers/env-handlers.ts index 661d2999..9574215b 100644 --- a/apps/frontend/src/main/ipc-handlers/env-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/env-handlers.ts @@ -141,6 +141,52 @@ export function registerEnvHandlers( existingVars['ENABLE_FANCY_UI'] = config.enableFancyUi ? 'true' : 'false'; } + // MCP Server Configuration + if (config.mcpServers) { + if (config.mcpServers.context7Enabled !== undefined) { + existingVars['CONTEXT7_ENABLED'] = config.mcpServers.context7Enabled ? 'true' : 'false'; + } + if (config.mcpServers.linearMcpEnabled !== undefined) { + existingVars['LINEAR_MCP_ENABLED'] = config.mcpServers.linearMcpEnabled ? 'true' : 'false'; + } + if (config.mcpServers.electronEnabled !== undefined) { + existingVars['ELECTRON_MCP_ENABLED'] = config.mcpServers.electronEnabled ? 'true' : 'false'; + } + if (config.mcpServers.puppeteerEnabled !== undefined) { + existingVars['PUPPETEER_MCP_ENABLED'] = config.mcpServers.puppeteerEnabled ? 'true' : 'false'; + } + // Note: graphitiEnabled is already handled via GRAPHITI_ENABLED above + } + + // Per-agent MCP overrides (add/remove MCPs from specific agents) + if (config.agentMcpOverrides) { + // First, clear any existing AGENT_MCP_* entries + Object.keys(existingVars).forEach(key => { + if (key.startsWith('AGENT_MCP_')) { + delete existingVars[key]; + } + }); + + // Add new overrides + Object.entries(config.agentMcpOverrides).forEach(([agentId, override]) => { + if (override.add && override.add.length > 0) { + existingVars[`AGENT_MCP_${agentId}_ADD`] = override.add.join(','); + } + if (override.remove && override.remove.length > 0) { + existingVars[`AGENT_MCP_${agentId}_REMOVE`] = override.remove.join(','); + } + }); + } + + // Custom MCP servers (user-defined) + if (config.customMcpServers !== undefined) { + if (config.customMcpServers.length > 0) { + existingVars['CUSTOM_MCP_SERVERS'] = JSON.stringify(config.customMcpServers); + } else { + delete existingVars['CUSTOM_MCP_SERVERS']; + } + } + // Generate content with sections const content = `# Auto Claude Framework Environment Variables # Managed by Auto Claude UI @@ -187,6 +233,36 @@ ${existingVars['DEFAULT_BRANCH'] ? `DEFAULT_BRANCH=${existingVars['DEFAULT_BRANC # ============================================================================= ${existingVars['ENABLE_FANCY_UI'] !== undefined ? `ENABLE_FANCY_UI=${existingVars['ENABLE_FANCY_UI']}` : '# ENABLE_FANCY_UI=true'} +# ============================================================================= +# MCP SERVER CONFIGURATION (per-project overrides) +# ============================================================================= +# Context7 documentation lookup (default: enabled) +${existingVars['CONTEXT7_ENABLED'] !== undefined ? `CONTEXT7_ENABLED=${existingVars['CONTEXT7_ENABLED']}` : '# CONTEXT7_ENABLED=true'} +# Linear MCP integration (default: follows LINEAR_API_KEY) +${existingVars['LINEAR_MCP_ENABLED'] !== undefined ? `LINEAR_MCP_ENABLED=${existingVars['LINEAR_MCP_ENABLED']}` : '# LINEAR_MCP_ENABLED=true'} +# Electron desktop automation - QA agents only (default: disabled) +${existingVars['ELECTRON_MCP_ENABLED'] !== undefined ? `ELECTRON_MCP_ENABLED=${existingVars['ELECTRON_MCP_ENABLED']}` : '# ELECTRON_MCP_ENABLED=false'} +# Puppeteer browser automation - QA agents only (default: disabled) +${existingVars['PUPPETEER_MCP_ENABLED'] !== undefined ? `PUPPETEER_MCP_ENABLED=${existingVars['PUPPETEER_MCP_ENABLED']}` : '# PUPPETEER_MCP_ENABLED=false'} + +# ============================================================================= +# PER-AGENT MCP OVERRIDES +# Add or remove MCP servers for specific agents +# Format: AGENT_MCP__ADD=server1,server2 +# Format: AGENT_MCP__REMOVE=server1,server2 +# ============================================================================= +${Object.entries(existingVars) + .filter(([key]) => key.startsWith('AGENT_MCP_')) + .map(([key, value]) => `${key}=${value}`) + .join('\n') || '# No per-agent overrides configured'} + +# ============================================================================= +# CUSTOM MCP SERVERS +# User-defined MCP servers (command-based or HTTP-based) +# JSON format: [{"id":"...","name":"...","type":"command|http",...}] +# ============================================================================= +${existingVars['CUSTOM_MCP_SERVERS'] ? `CUSTOM_MCP_SERVERS=${existingVars['CUSTOM_MCP_SERVERS']}` : '# CUSTOM_MCP_SERVERS=[]'} + # ============================================================================= # MEMORY INTEGRATION # Embedding providers: OpenAI, Google AI, Azure OpenAI, Ollama, Voyage @@ -389,6 +465,44 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ }; } + // MCP Server Configuration (per-project overrides) + // Default: context7=true, linear=true (if API key set), electron/puppeteer=false + config.mcpServers = { + context7Enabled: vars['CONTEXT7_ENABLED']?.toLowerCase() !== 'false', // default true + graphitiEnabled: config.graphitiEnabled, // follows GRAPHITI_ENABLED + linearMcpEnabled: vars['LINEAR_MCP_ENABLED']?.toLowerCase() !== 'false', // default true + electronEnabled: vars['ELECTRON_MCP_ENABLED']?.toLowerCase() === 'true', // default false + puppeteerEnabled: vars['PUPPETEER_MCP_ENABLED']?.toLowerCase() === 'true', // default false + }; + + // Parse per-agent MCP overrides (AGENT_MCP__ADD/REMOVE) + const agentMcpOverrides: Record = {}; + Object.entries(vars).forEach(([key, value]) => { + if (key.startsWith('AGENT_MCP_') && key.endsWith('_ADD')) { + const agentId = key.replace('AGENT_MCP_', '').replace('_ADD', ''); + if (!agentMcpOverrides[agentId]) agentMcpOverrides[agentId] = {}; + agentMcpOverrides[agentId].add = value.split(',').map(s => s.trim()).filter(Boolean); + } else if (key.startsWith('AGENT_MCP_') && key.endsWith('_REMOVE')) { + const agentId = key.replace('AGENT_MCP_', '').replace('_REMOVE', ''); + if (!agentMcpOverrides[agentId]) agentMcpOverrides[agentId] = {}; + agentMcpOverrides[agentId].remove = value.split(',').map(s => s.trim()).filter(Boolean); + } + }); + + if (Object.keys(agentMcpOverrides).length > 0) { + config.agentMcpOverrides = agentMcpOverrides; + } + + // Parse custom MCP servers (user-defined) + if (vars['CUSTOM_MCP_SERVERS']) { + try { + config.customMcpServers = JSON.parse(vars['CUSTOM_MCP_SERVERS']); + } catch { + // Invalid JSON, ignore + config.customMcpServers = []; + } + } + return { success: true, data: config }; } ); diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index ac1cd6a6..ba175abb 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -120,6 +120,8 @@ export interface NewCommitsCheck { newCommitCount: number; lastReviewedCommit?: string; currentHeadCommit?: string; + /** Whether new commits happened AFTER findings were posted (for "Ready for Follow-up" status) */ + hasCommitsAfterPosting?: boolean; } /** @@ -165,6 +167,342 @@ function getGitHubDir(project: Project): string { return path.join(project.path, '.auto-claude', 'github'); } +/** + * PR log phase type + */ +type PRLogPhase = 'context' | 'analysis' | 'synthesis'; + +/** + * PR log entry type + */ +type PRLogEntryType = 'text' | 'tool_start' | 'tool_end' | 'phase_start' | 'phase_end' | 'error' | 'success' | 'info'; + +/** + * Single PR log entry + */ +interface PRLogEntry { + timestamp: string; + type: PRLogEntryType; + content: string; + phase: PRLogPhase; + source?: string; + detail?: string; + collapsed?: boolean; +} + +/** + * Phase log with entries + */ +interface PRPhaseLog { + phase: PRLogPhase; + status: 'pending' | 'active' | 'completed' | 'failed'; + started_at: string | null; + completed_at: string | null; + entries: PRLogEntry[]; +} + +/** + * Complete PR logs structure + */ +interface PRLogs { + pr_number: number; + repo: string; + created_at: string; + updated_at: string; + is_followup: boolean; + phases: { + context: PRPhaseLog; + analysis: PRPhaseLog; + synthesis: PRPhaseLog; + }; +} + +/** + * Parse a log line and extract source and content + * Returns null if line is not a log line + */ +function parseLogLine(line: string): { source: string; content: string; isError: boolean } | null { + // Match patterns like [Context], [AI], [Orchestrator], [Followup], [DEBUG ...], [ParallelFollowup], [BotDetector], [ParallelOrchestrator] + const patterns = [ + /^\[Context\]\s*(.*)$/, + /^\[AI\]\s*(.*)$/, + /^\[Orchestrator\]\s*(.*)$/, + /^\[Followup\]\s*(.*)$/, + /^\[ParallelFollowup\]\s*(.*)$/, + /^\[ParallelOrchestrator\]\s*(.*)$/, + /^\[BotDetector\]\s*(.*)$/, + /^\[PR Review Engine\]\s*(.*)$/, + /^\[DEBUG\s+(\w+)\]\s*(.*)$/, + /^\[ERROR\s+(\w+)\]\s*(.*)$/, + ]; + + // Check for specialist agent logs first (Agent:agent-name format) + const agentMatch = line.match(/^\[Agent:([\w-]+)\]\s*(.*)$/); + if (agentMatch) { + return { + source: `Agent:${agentMatch[1]}`, + content: agentMatch[2], + isError: false, + }; + } + + for (const pattern of patterns) { + const match = line.match(pattern); + if (match) { + const isDebugOrError = pattern.source.includes('DEBUG') || pattern.source.includes('ERROR'); + if (isDebugOrError && match.length >= 3) { + // Skip debug messages that only show message types (not useful) + if (match[2].match(/^Message #\d+: \w+Message/)) { + return null; + } + return { + source: match[1], + content: match[2], + isError: pattern.source.includes('ERROR'), + }; + } + const source = line.match(/^\[(\w+(?:\s+\w+)*)\]/)?.[1] || 'Unknown'; + return { + source, + content: match[1] || line, + isError: false, + }; + } + } + + // Check for PR progress messages [PR #XXX] [YY%] message + const prProgressMatch = line.match(/^\[PR #\d+\]\s*\[\s*(\d+)%\]\s*(.*)$/); + if (prProgressMatch) { + return { + source: 'Progress', + content: `[${prProgressMatch[1]}%] ${prProgressMatch[2]}`, + isError: false, + }; + } + + // Check for progress messages [XX%] + const progressMatch = line.match(/^\[(\d+)%\]\s*(.*)$/); + if (progressMatch) { + return { + source: 'Progress', + content: `[${progressMatch[1]}%] ${progressMatch[2]}`, + isError: false, + }; + } + + // Match final summary lines (Status:, Summary:, Findings:, etc.) + const summaryPatterns = [ + /^(Status|Summary|Findings|Verdict|Is Follow-up|Resolved|Still Open|New Issues):\s*(.*)$/, + /^PR #\d+ (Follow-up )?Review Complete$/, + /^={10,}$/, + /^-{10,}$/, + // Markdown headers (## Summary, ### Resolution Status, etc.) + /^#{1,4}\s+.+$/, + // Bullet points with content (- ✅ **Resolved**, - **Blocking Issues**, etc.) + /^[-*]\s+.+$/, + // Indented bullet points for findings ( - [MEDIUM] ..., . [LOW] ...) + /^\s+[-.*]\s+\[.+$/, + // Lines with bold text at start (**Why NEEDS_REVISION:**, **Recommended Actions:**) + /^\*\*.+\*\*:?\s*$/, + // Numbered list items (1. Add DANGEROUS_FLAGS...) + /^\d+\.\s+.+$/, + // File references (File: apps/backend/...) + /^\s+File:\s+.+$/, + ]; + for (const pattern of summaryPatterns) { + const match = line.match(pattern); + if (match) { + return { + source: 'Summary', + content: line, + isError: false, + }; + } + } + + return null; +} + +/** + * Determine the phase from source + */ +function getPhaseFromSource(source: string): PRLogPhase { + const contextSources = ['Context', 'BotDetector']; + const analysisSources = ['AI', 'Orchestrator', 'ParallelOrchestrator', 'ParallelFollowup', 'Followup', 'orchestrator']; + const synthesisSources = ['PR Review Engine', 'Summary', 'Progress']; + + if (contextSources.includes(source)) return 'context'; + if (analysisSources.includes(source)) return 'analysis'; + // Specialist agents (Agent:xxx) are part of analysis phase + if (source.startsWith('Agent:')) return 'analysis'; + if (synthesisSources.includes(source)) return 'synthesis'; + return 'synthesis'; // Default to synthesis for unknown sources +} + +/** + * Create empty PR logs structure + */ +function createEmptyPRLogs(prNumber: number, repo: string, isFollowup: boolean): PRLogs { + const now = new Date().toISOString(); + const createEmptyPhase = (phase: PRLogPhase): PRPhaseLog => ({ + phase, + status: 'pending', + started_at: null, + completed_at: null, + entries: [], + }); + + return { + pr_number: prNumber, + repo, + created_at: now, + updated_at: now, + is_followup: isFollowup, + phases: { + context: createEmptyPhase('context'), + analysis: createEmptyPhase('analysis'), + synthesis: createEmptyPhase('synthesis'), + }, + }; +} + +/** + * Get PR logs file path + */ +function getPRLogsPath(project: Project, prNumber: number): string { + return path.join(getGitHubDir(project), 'pr', `logs_${prNumber}.json`); +} + +/** + * Load PR logs from disk + */ +function loadPRLogs(project: Project, prNumber: number): PRLogs | null { + const logsPath = getPRLogsPath(project, prNumber); + + try { + const rawData = fs.readFileSync(logsPath, 'utf-8'); + const sanitizedData = sanitizeNetworkData(rawData); + return JSON.parse(sanitizedData) as PRLogs; + } catch { + return null; + } +} + +/** + * Save PR logs to disk + */ +function savePRLogs(project: Project, logs: PRLogs): void { + const logsPath = getPRLogsPath(project, logs.pr_number); + const prDir = path.dirname(logsPath); + + if (!fs.existsSync(prDir)) { + fs.mkdirSync(prDir, { recursive: true }); + } + + logs.updated_at = new Date().toISOString(); + fs.writeFileSync(logsPath, JSON.stringify(logs, null, 2), 'utf-8'); +} + +/** + * Add a log entry to PR logs + */ +function addLogEntry(logs: PRLogs, entry: PRLogEntry): void { + const phase = logs.phases[entry.phase]; + + // Update phase status if needed + if (phase.status === 'pending') { + phase.status = 'active'; + phase.started_at = entry.timestamp; + } + + phase.entries.push(entry); +} + +/** + * PR Log Collector - collects logs during review + * Saves incrementally to disk so frontend can stream logs in real-time + */ +class PRLogCollector { + private logs: PRLogs; + private project: Project; + private currentPhase: PRLogPhase = 'context'; + private entryCount: number = 0; + private saveInterval: number = 3; // Save every N entries for real-time streaming + + constructor(project: Project, prNumber: number, repo: string, isFollowup: boolean) { + this.project = project; + this.logs = createEmptyPRLogs(prNumber, repo, isFollowup); + // Save initial empty logs so frontend sees the structure immediately + this.save(); + } + + processLine(line: string): void { + const parsed = parseLogLine(line); + if (!parsed) return; + + const phase = getPhaseFromSource(parsed.source); + + // Track phase transitions - mark previous phases as complete + if (phase !== this.currentPhase) { + // When moving to a new phase, mark the previous phase as complete + if (this.currentPhase === 'context' && (phase === 'analysis' || phase === 'synthesis')) { + this.markPhaseComplete('context', true); + } + if (this.currentPhase === 'analysis' && phase === 'synthesis') { + this.markPhaseComplete('analysis', true); + } + this.currentPhase = phase; + } + + const entry: PRLogEntry = { + timestamp: new Date().toISOString(), + type: parsed.isError ? 'error' : 'text', + content: parsed.content, + phase, + source: parsed.source, + }; + + addLogEntry(this.logs, entry); + this.entryCount++; + + // Save periodically for real-time streaming (every N entries) + if (this.entryCount % this.saveInterval === 0) { + this.save(); + } + } + + markPhaseComplete(phase: PRLogPhase, success: boolean): void { + const phaseLog = this.logs.phases[phase]; + phaseLog.status = success ? 'completed' : 'failed'; + phaseLog.completed_at = new Date().toISOString(); + // Save immediately so frontend sees the status change + this.save(); + } + + save(): void { + savePRLogs(this.project, this.logs); + } + + finalize(success: boolean): void { + // Mark all phases as completed based on success status + // For phases with entries: mark based on success + // For phases without entries (pending): mark as completed if previous phases completed + let previousCompleted = true; + for (const phase of ['context', 'analysis', 'synthesis'] as PRLogPhase[]) { + const phaseLog = this.logs.phases[phase]; + if (phaseLog.status === 'active') { + this.markPhaseComplete(phase, success); + previousCompleted = success; + } else if (phaseLog.status === 'pending' && previousCompleted && success) { + // If review succeeded, mark pending phases as completed (they just had no logs) + phaseLog.status = 'completed'; + phaseLog.completed_at = new Date().toISOString(); + } + } + this.save(); + } +} + /** * Get saved PR review result */ @@ -278,10 +616,22 @@ async function runPRReview( debugLog('Spawning PR review process', { args, model, thinkingLevel }); + // Create log collector for this review + const config = getGitHubConfig(project); + const repo = config?.repo || project.name || 'unknown'; + const logCollector = new PRLogCollector(project, prNumber, repo, false); + + // Build environment with project settings + const subprocessEnv: Record = {}; + if (project.settings?.useClaudeMd !== false) { + subprocessEnv['USE_CLAUDE_MD'] = 'true'; + } + const { process: childProcess, promise } = runPythonSubprocess({ pythonPath: getPythonPath(backendPath), args, cwd: backendPath, + env: subprocessEnv, onProgress: (percent, message) => { debugLog('Progress update', { percent, message }); sendProgress({ @@ -291,7 +641,11 @@ async function runPRReview( message, }); }, - onStdout: (line) => debugLog('STDOUT:', line), + onStdout: (line) => { + debugLog('STDOUT:', line); + // Collect log entries + logCollector.processLine(line); + }, onStderr: (line) => debugLog('STDERR:', line), onComplete: () => { // Load the result from disk @@ -314,9 +668,13 @@ async function runPRReview( const result = await promise; if (!result.success) { + // Finalize logs with failure + logCollector.finalize(false); throw new Error(result.error ?? 'Review failed'); } + // Finalize logs with success + logCollector.finalize(true); return result.data!; } finally { // Clean up the registry when done (success or error) @@ -500,6 +858,16 @@ export function registerPRHandlers( } ); + // Get PR review logs + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_GET_LOGS, + async (_, projectId: string, prNumber: number): Promise => { + return withProjectOrNull(projectId, async (project) => { + return loadPRLogs(project, prNumber); + }); + } + ); + // Run AI review ipcMain.on( IPC_CHANNELS.GITHUB_PR_REVIEW, @@ -982,6 +1350,7 @@ export function registerPRHandlers( newCommitCount: 0, lastReviewedCommit: reviewedCommitSha, currentHeadCommit: currentHeadSha, + hasCommitsAfterPosting: false, }; } @@ -989,13 +1358,36 @@ export function registerPRHandlers( const comparison = (await githubFetch( config.token, `/repos/${config.repo}/compare/${reviewedCommitSha}...${currentHeadSha}` - )) as { ahead_by?: number; total_commits?: number }; + )) as { ahead_by?: number; total_commits?: number; commits?: Array<{ commit: { committer: { date: string } } }> }; + + // Check if findings have been posted and if new commits are after the posting date + const postedAt = review.postedAt || (review as any).posted_at; + let hasCommitsAfterPosting = true; // Default to true if we can't determine + + if (postedAt && comparison.commits && comparison.commits.length > 0) { + const postedAtDate = new Date(postedAt); + // Check if any commit is newer than when findings were posted + hasCommitsAfterPosting = comparison.commits.some(c => { + const commitDate = new Date(c.commit.committer.date); + return commitDate > postedAtDate; + }); + debugLog('Comparing commit dates with posted_at', { + prNumber, + postedAt, + latestCommitDate: comparison.commits[comparison.commits.length - 1]?.commit.committer.date, + hasCommitsAfterPosting, + }); + } else if (!postedAt) { + // If findings haven't been posted yet, any new commits should trigger follow-up + hasCommitsAfterPosting = true; + } return { hasNewCommits: true, newCommitCount: comparison.ahead_by || comparison.total_commits || 1, lastReviewedCommit: reviewedCommitSha, currentHeadCommit: currentHeadSha, + hasCommitsAfterPosting, }; } catch (error) { debugLog('Error checking new commits', { prNumber, error: error instanceof Error ? error.message : error }); @@ -1065,10 +1457,22 @@ export function registerPRHandlers( debugLog('Spawning follow-up review process', { args, model, thinkingLevel }); + // Create log collector for this follow-up review + const config = getGitHubConfig(project); + const repo = config?.repo || project.name || 'unknown'; + const logCollector = new PRLogCollector(project, prNumber, repo, true); + + // Build environment with project settings + const followupEnv: Record = {}; + if (project.settings?.useClaudeMd !== false) { + followupEnv['USE_CLAUDE_MD'] = 'true'; + } + const { process: childProcess, promise } = runPythonSubprocess({ pythonPath: getPythonPath(backendPath), args, cwd: backendPath, + env: followupEnv, onProgress: (percent, message) => { debugLog('Progress update', { percent, message }); sendProgress({ @@ -1078,7 +1482,11 @@ export function registerPRHandlers( message, }); }, - onStdout: (line) => debugLog('STDOUT:', line), + onStdout: (line) => { + debugLog('STDOUT:', line); + // Collect log entries + logCollector.processLine(line); + }, onStderr: (line) => debugLog('STDERR:', line), onComplete: () => { // Load the result from disk @@ -1099,9 +1507,14 @@ export function registerPRHandlers( const result = await promise; if (!result.success) { + // Finalize logs with failure + logCollector.finalize(false); throw new Error(result.error ?? 'Follow-up review failed'); } + // Finalize logs with success + logCollector.finalize(true); + debugLog('Follow-up review completed', { prNumber, findingsCount: result.data?.findings.length }); sendProgress({ phase: 'complete', diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts index f53f4170..db6ae7dc 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts @@ -28,6 +28,8 @@ export interface SubprocessOptions { onComplete?: (stdout: string, stderr: string) => unknown; onError?: (error: string) => void; progressPattern?: RegExp; + /** Additional environment variables to pass to the subprocess */ + env?: Record; } /** @@ -75,6 +77,13 @@ export function runPythonSubprocess( } } + // Merge in any additional env vars passed by the caller (e.g., USE_CLAUDE_MD) + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + filteredEnv[key] = value; + } + } + // Parse Python command to handle paths with spaces (e.g., ~/Library/Application Support/...) const [pythonCommand, pythonBaseArgs] = parsePythonCommand(options.pythonPath); const child = spawn(pythonCommand, [...pythonBaseArgs, ...options.args], { diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts index fce205ed..f1a76fb3 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts @@ -8,6 +8,7 @@ import { execSync, execFileSync, spawn } from 'child_process'; import { IPC_CHANNELS } from '../../../shared/constants'; import type { IPCResult } from '../../../shared/types'; import { getAugmentedEnv, findExecutable } from '../../env-utils'; +import { openTerminalWithCommand } from '../claude-code-handlers'; import type { GitLabAuthStartResult } from './types'; const DEFAULT_GITLAB_URL = 'https://gitlab.com'; @@ -118,6 +119,51 @@ export function registerCheckGlabCli(): void { ); } +/** + * Install glab CLI by opening a terminal with the appropriate install command + * Uses the user's preferred terminal from settings + */ +export function registerInstallGlabCli(): void { + ipcMain.handle( + IPC_CHANNELS.GITLAB_INSTALL_CLI, + async (): Promise> => { + debugLog('installGitLabCli handler called'); + try { + const platform = process.platform; + let command: string; + + if (platform === 'darwin') { + // macOS: Use Homebrew + command = 'brew install glab'; + } else if (platform === 'win32') { + // Windows: Use winget + command = 'winget install --id GitLab.glab'; + } else { + // Linux: Try snap first, then homebrew + command = 'sudo snap install glab || brew install glab'; + } + + debugLog('Install command:', command); + debugLog('Opening terminal...'); + await openTerminalWithCommand(command); + debugLog('Terminal opened successfully'); + + return { + success: true, + data: { command } + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + debugLog('Install failed:', errorMsg); + return { + success: false, + error: `Failed to open terminal for installation: ${errorMsg}` + }; + } + } + ); +} + /** * Check if user is authenticated with glab CLI */ @@ -717,6 +763,7 @@ export function registerListGitLabGroups(): void { export function registerGitlabOAuthHandlers(): void { debugLog('Registering GitLab OAuth handlers'); registerCheckGlabCli(); + registerInstallGlabCli(); registerCheckGlabAuth(); registerStartGlabAuth(); registerGetGlabToken(); diff --git a/apps/frontend/src/main/ipc-handlers/index.ts b/apps/frontend/src/main/ipc-handlers/index.ts index 7a525a80..3501abd8 100644 --- a/apps/frontend/src/main/ipc-handlers/index.ts +++ b/apps/frontend/src/main/ipc-handlers/index.ts @@ -30,6 +30,8 @@ import { registerInsightsHandlers } from './insights-handlers'; import { registerMemoryHandlers } from './memory-handlers'; import { registerAppUpdateHandlers } from './app-update-handlers'; import { registerDebugHandlers } from './debug-handlers'; +import { registerClaudeCodeHandlers } from './claude-code-handlers'; +import { registerMcpHandlers } from './mcp-handlers'; import { notificationService } from '../notification-service'; /** @@ -106,6 +108,12 @@ export function setupIpcHandlers( // Debug handlers (logs, debug info, etc.) registerDebugHandlers(); + // Claude Code CLI handlers (version checking, installation) + registerClaudeCodeHandlers(); + + // MCP server health check handlers + registerMcpHandlers(); + console.warn('[IPC] All handler modules registered successfully'); } @@ -129,5 +137,7 @@ export { registerInsightsHandlers, registerMemoryHandlers, registerAppUpdateHandlers, - registerDebugHandlers + registerDebugHandlers, + registerClaudeCodeHandlers, + registerMcpHandlers }; diff --git a/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts b/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts new file mode 100644 index 00000000..05155299 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts @@ -0,0 +1,547 @@ +/** + * MCP Server Health Check Handlers + * + * Handles IPC requests for checking MCP server health and connectivity. + */ + +import { ipcMain } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import type { CustomMcpServer, McpHealthCheckResult, McpHealthStatus, McpTestConnectionResult } from '../../shared/types/project'; +import { spawn } from 'child_process'; +import { appLog } from '../app-logger'; + +/** + * Defense-in-depth: Frontend-side command validation + * Mirrors the backend SAFE_COMMANDS allowlist to prevent arbitrary command execution + * even if malicious configs somehow bypass backend validation + */ +const SAFE_COMMANDS = new Set(['npx', 'npm', 'node', 'python', 'python3', 'uv', 'uvx']); + +/** + * Defense-in-depth: Dangerous interpreter flags that allow code execution + * Mirrors backend DANGEROUS_FLAGS to prevent args-based code injection + */ +const DANGEROUS_FLAGS = new Set([ + '--eval', '-e', '-c', '--exec', + '-m', '-p', '--print', + '--input-type=module', '--experimental-loader', + '--require', '-r' +]); + +/** + * Validate that a command is in the safe allowlist + */ +function isCommandSafe(command: string | undefined): boolean { + if (!command) return false; + // Reject commands with paths (defense against path traversal) + if (command.includes('/') || command.includes('\\')) return false; + return SAFE_COMMANDS.has(command); +} + +/** + * Validate that args don't contain dangerous interpreter flags + */ +function areArgsSafe(args: string[] | undefined): boolean { + if (!args || args.length === 0) return true; + return !args.some(arg => DANGEROUS_FLAGS.has(arg)); +} + +/** + * Quick health check for a custom MCP server. + * For HTTP servers: makes a HEAD/GET request to check connectivity. + * For command servers: checks if the command exists. + */ +async function checkMcpHealth(server: CustomMcpServer): Promise { + const startTime = Date.now(); + + if (server.type === 'http') { + return checkHttpHealth(server, startTime); + } else { + return checkCommandHealth(server, startTime); + } +} + +/** + * Check HTTP server health by making a request. + */ +async function checkHttpHealth(server: CustomMcpServer, startTime: number): Promise { + if (!server.url) { + return { + serverId: server.id, + status: 'unhealthy', + message: 'No URL configured', + checkedAt: new Date().toISOString(), + }; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const headers: Record = { + 'Accept': 'application/json', + }; + + // Add custom headers if configured + if (server.headers) { + Object.assign(headers, server.headers); + } + + const response = await fetch(server.url, { + method: 'GET', + headers, + signal: controller.signal, + }); + + clearTimeout(timeout); + const responseTime = Date.now() - startTime; + + let status: McpHealthStatus; + let message: string; + + if (response.ok) { + status = 'healthy'; + message = 'Server is responding'; + } else if (response.status === 401 || response.status === 403) { + status = 'needs_auth'; + message = response.status === 401 ? 'Authentication required' : 'Access forbidden'; + } else { + status = 'unhealthy'; + message = `HTTP ${response.status}: ${response.statusText}`; + } + + return { + serverId: server.id, + status, + statusCode: response.status, + message, + responseTime, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + const responseTime = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Check for specific error types + const status: McpHealthStatus = 'unhealthy'; + let message = errorMessage; + + if (errorMessage.includes('abort') || errorMessage.includes('timeout')) { + message = 'Connection timed out'; + } else if (errorMessage.includes('ECONNREFUSED')) { + message = 'Connection refused - server may be down'; + } else if (errorMessage.includes('ENOTFOUND')) { + message = 'Server not found - check URL'; + } + + return { + serverId: server.id, + status, + message, + responseTime, + checkedAt: new Date().toISOString(), + }; + } +} + +/** + * Check command-based server health by verifying the command exists. + */ +async function checkCommandHealth(server: CustomMcpServer, startTime: number): Promise { + if (!server.command) { + return { + serverId: server.id, + status: 'unhealthy', + message: 'No command configured', + checkedAt: new Date().toISOString(), + }; + } + + return new Promise((resolve) => { + // Defense-in-depth: Validate command and args before spawn + if (!isCommandSafe(server.command)) { + return resolve({ + serverId: server.id, + status: 'unhealthy', + message: `Invalid command '${server.command}' - not in allowlist`, + checkedAt: new Date().toISOString(), + }); + } + if (!areArgsSafe(server.args)) { + return resolve({ + serverId: server.id, + status: 'unhealthy', + message: 'Args contain dangerous interpreter flags', + checkedAt: new Date().toISOString(), + }); + } + + const command = process.platform === 'win32' ? 'where' : 'which'; + const proc = spawn(command, [server.command!], { + timeout: 5000, + }); + + let found = false; + + proc.on('close', (code) => { + const responseTime = Date.now() - startTime; + + if (code === 0 || found) { + resolve({ + serverId: server.id, + status: 'healthy', + message: `Command '${server.command}' found`, + responseTime, + checkedAt: new Date().toISOString(), + }); + } else { + resolve({ + serverId: server.id, + status: 'unhealthy', + message: `Command '${server.command}' not found in PATH`, + responseTime, + checkedAt: new Date().toISOString(), + }); + } + }); + + proc.stdout.on('data', () => { + found = true; + }); + + proc.on('error', () => { + const responseTime = Date.now() - startTime; + resolve({ + serverId: server.id, + status: 'unhealthy', + message: `Failed to check command '${server.command}'`, + responseTime, + checkedAt: new Date().toISOString(), + }); + }); + }); +} + +/** + * Full MCP connection test - actually connects to the server and tries to list tools. + * This is more thorough but slower than the health check. + */ +async function testMcpConnection(server: CustomMcpServer): Promise { + const startTime = Date.now(); + + if (server.type === 'http') { + return testHttpConnection(server, startTime); + } else { + return testCommandConnection(server, startTime); + } +} + +/** + * Test HTTP MCP server connection by sending an MCP initialize request. + */ +async function testHttpConnection(server: CustomMcpServer, startTime: number): Promise { + if (!server.url) { + return { + serverId: server.id, + success: false, + message: 'No URL configured', + }; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); // 30 second timeout + + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + if (server.headers) { + Object.assign(headers, server.headers); + } + + // Send MCP initialize request + const initRequest = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'auto-claude-health-check', + version: '1.0.0', + }, + }, + }; + + const response = await fetch(server.url, { + method: 'POST', + headers, + body: JSON.stringify(initRequest), + signal: controller.signal, + }); + + clearTimeout(timeout); + const responseTime = Date.now() - startTime; + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return { + serverId: server.id, + success: false, + message: 'Authentication failed', + error: `HTTP ${response.status}: ${response.statusText}`, + responseTime, + }; + } + return { + serverId: server.id, + success: false, + message: `Server returned error`, + error: `HTTP ${response.status}: ${response.statusText}`, + responseTime, + }; + } + + const data = await response.json(); + + if (data.error) { + return { + serverId: server.id, + success: false, + message: 'MCP error', + error: data.error.message || JSON.stringify(data.error), + responseTime, + }; + } + + // Now try to list tools + const toolsRequest = { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }; + + const toolsResponse = await fetch(server.url, { + method: 'POST', + headers, + body: JSON.stringify(toolsRequest), + }); + + let tools: string[] = []; + if (toolsResponse.ok) { + const toolsData = await toolsResponse.json(); + if (toolsData.result?.tools) { + tools = toolsData.result.tools.map((t: { name: string }) => t.name); + } + } + + return { + serverId: server.id, + success: true, + message: tools.length > 0 ? `Connected successfully, ${tools.length} tools available` : 'Connected successfully', + tools, + responseTime, + }; + } catch (error) { + const responseTime = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + let message = 'Connection failed'; + if (errorMessage.includes('abort') || errorMessage.includes('timeout')) { + message = 'Connection timed out'; + } else if (errorMessage.includes('ECONNREFUSED')) { + message = 'Connection refused - server may be down'; + } else if (errorMessage.includes('ENOTFOUND')) { + message = 'Server not found - check URL'; + } + + return { + serverId: server.id, + success: false, + message, + error: errorMessage, + responseTime, + }; + } +} + +/** + * Test command-based MCP server connection by spawning the process and trying to communicate. + */ +async function testCommandConnection(server: CustomMcpServer, startTime: number): Promise { + if (!server.command) { + return { + serverId: server.id, + success: false, + message: 'No command configured', + }; + } + + return new Promise((resolve) => { + // Defense-in-depth: Validate command and args before spawn + if (!isCommandSafe(server.command)) { + return resolve({ + serverId: server.id, + success: false, + message: `Invalid command '${server.command}' - not in allowlist`, + }); + } + if (!areArgsSafe(server.args)) { + return resolve({ + serverId: server.id, + success: false, + message: 'Args contain dangerous interpreter flags', + }); + } + + const args = server.args || []; + const proc = spawn(server.command!, args, { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 15000, // OS-level timeout for reliable process termination + }); + + let stdout = ''; + let stderr = ''; + let resolved = false; + + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + proc.kill(); + const responseTime = Date.now() - startTime; + resolve({ + serverId: server.id, + success: false, + message: 'Connection timed out', + responseTime, + }); + } + }, 15000); // 15 second timeout (matches spawn timeout) + + // Send MCP initialize request + const initRequest = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'auto-claude-health-check', + version: '1.0.0', + }, + }, + }) + '\n'; + + proc.stdin.write(initRequest); + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + + // Try to parse JSON response + try { + const lines = stdout.split('\n').filter(l => l.trim()); + for (const line of lines) { + const response = JSON.parse(line); + if (response.id === 1 && response.result) { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + proc.kill(); + const responseTime = Date.now() - startTime; + resolve({ + serverId: server.id, + success: true, + message: 'MCP server started successfully', + responseTime, + }); + } + return; + } + } + } catch { + // Not valid JSON yet, keep waiting + } + }); + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + const responseTime = Date.now() - startTime; + resolve({ + serverId: server.id, + success: false, + message: 'Failed to start server', + error: error.message, + responseTime, + }); + } + }); + + proc.on('close', (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + const responseTime = Date.now() - startTime; + if (code === 0) { + resolve({ + serverId: server.id, + success: true, + message: 'Server process started', + responseTime, + }); + } else { + resolve({ + serverId: server.id, + success: false, + message: `Server exited with code ${code}`, + error: stderr || undefined, + responseTime, + }); + } + } + }); + }); +} + +/** + * Register MCP IPC handlers. + */ +export function registerMcpHandlers(): void { + // Quick health check + ipcMain.handle(IPC_CHANNELS.MCP_CHECK_HEALTH, async (_event, server: CustomMcpServer) => { + try { + const result = await checkMcpHealth(server); + return { success: true, data: result }; + } catch (error) { + appLog.error('MCP health check error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Health check failed', + }; + } + }); + + // Full connection test + ipcMain.handle(IPC_CHANNELS.MCP_TEST_CONNECTION, async (_event, server: CustomMcpServer) => { + try { + const result = await testMcpConnection(server); + return { success: true, data: result }; + } catch (error) { + appLog.error('MCP connection test error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Connection test failed', + }; + } + }); +} diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index a3d7fa78..a9edf89c 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -1,6 +1,6 @@ -import { ipcMain, BrowserWindow, shell } from 'electron'; -import { IPC_CHANNELS, AUTO_BUILD_PATHS } from '../../../shared/constants'; -import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, SupportedIDE, SupportedTerminal } from '../../../shared/types'; +import { ipcMain, BrowserWindow, shell, app } from 'electron'; +import { IPC_CHANNELS, AUTO_BUILD_PATHS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING, MODEL_ID_MAP, THINKING_BUDGET_MAP } from '../../../shared/constants'; +import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, SupportedIDE, SupportedTerminal, AppSettings } from '../../../shared/types'; import path from 'path'; import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; @@ -13,6 +13,45 @@ import { parsePythonCommand } from '../../python-detector'; import { getToolPath } from '../../cli-tool-manager'; import { promisify } from 'util'; +/** + * Read utility feature settings (for commit message, merge resolver) from settings file + */ +function getUtilitySettings(): { model: string; modelId: string; thinkingLevel: string; thinkingBudget: number | null } { + const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + + try { + if (existsSync(settingsPath)) { + const content = readFileSync(settingsPath, 'utf-8'); + const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) }; + + // Get utility-specific settings + const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS; + const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING; + + const model = featureModels.utility || DEFAULT_FEATURE_MODELS.utility; + const thinkingLevel = featureThinking.utility || DEFAULT_FEATURE_THINKING.utility; + + return { + model, + modelId: MODEL_ID_MAP[model] || MODEL_ID_MAP.haiku, + thinkingLevel, + thinkingBudget: thinkingLevel in THINKING_BUDGET_MAP ? THINKING_BUDGET_MAP[thinkingLevel] : THINKING_BUDGET_MAP.low + }; + } + } catch (error) { + // Log parse errors to help diagnose corrupted settings + console.warn('[getUtilitySettings] Failed to parse settings.json:', error); + } + + // Return defaults if settings file doesn't exist or fails to parse + return { + model: DEFAULT_FEATURE_MODELS.utility, + modelId: MODEL_ID_MAP[DEFAULT_FEATURE_MODELS.utility], + thinkingLevel: DEFAULT_FEATURE_THINKING.utility, + thinkingBudget: THINKING_BUDGET_MAP[DEFAULT_FEATURE_THINKING.utility] + }; +} + const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -1453,6 +1492,10 @@ export function registerWorktreeHandlers( // Get Python environment for bundled packages const pythonEnv = pythonEnvManagerSingleton.getPythonEnv(); + // Get utility settings for merge resolver + const utilitySettings = getUtilitySettings(); + debug('Utility settings for merge:', utilitySettings); + // Parse Python command to handle space-separated commands like "py -3" const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath); const mergeProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], { @@ -1462,7 +1505,11 @@ export function registerWorktreeHandlers( ...pythonEnv, // Include bundled packages PYTHONPATH ...profileEnv, // Include active Claude profile OAuth token PYTHONUNBUFFERED: '1', - PYTHONUTF8: '1' + PYTHONUTF8: '1', + // Utility feature settings for merge resolver + UTILITY_MODEL: utilitySettings.model, + UTILITY_MODEL_ID: utilitySettings.modelId, + UTILITY_THINKING_BUDGET: utilitySettings.thinkingBudget === null ? '' : (utilitySettings.thinkingBudget?.toString() || '') }, stdio: ['ignore', 'pipe', 'pipe'] // Don't connect stdin to avoid blocking }); @@ -1577,8 +1624,9 @@ export function registerWorktreeHandlers( try { // Check if current branch contains all commits from spec branch // git merge-base --is-ancestor returns exit code 0 if true, 1 if false - execSync( - `git merge-base --is-ancestor ${specBranch} HEAD`, + execFileSync( + 'git', + ['merge-base', '--is-ancestor', specBranch, 'HEAD'], { cwd: project.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } ); // If we reach here, the command succeeded (exit code 0) - branch is merged diff --git a/apps/frontend/src/preload/api/index.ts b/apps/frontend/src/preload/api/index.ts index 0986b081..51e28c76 100644 --- a/apps/frontend/src/preload/api/index.ts +++ b/apps/frontend/src/preload/api/index.ts @@ -10,6 +10,8 @@ import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api'; import { GitHubAPI, createGitHubAPI } from './modules/github-api'; import { GitLabAPI, createGitLabAPI } from './modules/gitlab-api'; import { DebugAPI, createDebugAPI } from './modules/debug-api'; +import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api'; +import { McpAPI, createMcpAPI } from './modules/mcp-api'; export interface ElectronAPI extends ProjectAPI, @@ -22,7 +24,9 @@ export interface ElectronAPI extends InsightsAPI, AppUpdateAPI, GitLabAPI, - DebugAPI { + DebugAPI, + ClaudeCodeAPI, + McpAPI { github: GitHubAPI; } @@ -38,6 +42,8 @@ export const createElectronAPI = (): ElectronAPI => ({ ...createAppUpdateAPI(), ...createGitLabAPI(), ...createDebugAPI(), + ...createClaudeCodeAPI(), + ...createMcpAPI(), github: createGitHubAPI() }); @@ -54,7 +60,9 @@ export { createAppUpdateAPI, createGitHubAPI, createGitLabAPI, - createDebugAPI + createDebugAPI, + createClaudeCodeAPI, + createMcpAPI }; export type { @@ -69,5 +77,7 @@ export type { AppUpdateAPI, GitHubAPI, GitLabAPI, - DebugAPI + DebugAPI, + ClaudeCodeAPI, + McpAPI }; diff --git a/apps/frontend/src/preload/api/modules/claude-code-api.ts b/apps/frontend/src/preload/api/modules/claude-code-api.ts new file mode 100644 index 00000000..7c39044b --- /dev/null +++ b/apps/frontend/src/preload/api/modules/claude-code-api.ts @@ -0,0 +1,59 @@ +/** + * Claude Code API for renderer process + * + * Provides access to Claude Code CLI management: + * - Check installed vs latest version + * - Install or update Claude Code + */ + +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { ClaudeCodeVersionInfo } from '../../../shared/types/cli'; +import { invokeIpc } from './ipc-utils'; + +/** + * Result of Claude Code installation attempt + */ +export interface ClaudeCodeInstallResult { + success: boolean; + data?: { + command: string; + }; + error?: string; +} + +/** + * Result of version check + */ +export interface ClaudeCodeVersionResult { + success: boolean; + data?: ClaudeCodeVersionInfo; + error?: string; +} + +/** + * Claude Code API interface exposed to renderer + */ +export interface ClaudeCodeAPI { + /** + * Check Claude Code CLI version status + * Returns installed version, latest version, and whether update is available + */ + checkClaudeCodeVersion: () => Promise; + + /** + * Install or update Claude Code CLI + * Opens the user's terminal with the install command + */ + installClaudeCode: () => Promise; +} + +/** + * Creates the Claude Code API implementation + */ +export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({ + checkClaudeCodeVersion: (): Promise => + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION), + + installClaudeCode: (): Promise => + invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL) +}); diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 9b9e0fc4..7436f873 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -248,6 +248,9 @@ export interface GitHubAPI { checkNewCommits: (projectId: string, prNumber: number) => Promise; runFollowupReview: (projectId: string, prNumber: number) => void; + // PR logs + getPRLogs: (projectId: string, prNumber: number) => Promise; + // PR event listeners onPRReviewProgress: ( callback: (projectId: string, progress: PRReviewProgress) => void @@ -336,6 +339,8 @@ export interface NewCommitsCheck { newCommitCount: number; lastReviewedCommit?: string; currentHeadCommit?: string; + /** Whether new commits happened AFTER findings were posted (for "Ready for Follow-up" status) */ + hasCommitsAfterPosting?: boolean; } /** @@ -348,6 +353,56 @@ export interface PRReviewProgress { message: string; } +/** + * PR review log entry type + */ +export type PRLogEntryType = 'text' | 'tool_start' | 'tool_end' | 'phase_start' | 'phase_end' | 'error' | 'success' | 'info'; + +/** + * PR review log phase + */ +export type PRLogPhase = 'context' | 'analysis' | 'synthesis'; + +/** + * Single log entry in PR review + */ +export interface PRLogEntry { + timestamp: string; + type: PRLogEntryType; + content: string; + phase: PRLogPhase; + source?: string; // e.g., 'Context', 'AI', 'Orchestrator', 'ParallelFollowup' + detail?: string; // Expandable detail content + collapsed?: boolean; +} + +/** + * Phase log containing entries + */ +export interface PRPhaseLog { + phase: PRLogPhase; + status: 'pending' | 'active' | 'completed' | 'failed'; + started_at: string | null; + completed_at: string | null; + entries: PRLogEntry[]; +} + +/** + * Complete PR review logs + */ +export interface PRLogs { + pr_number: number; + repo: string; + created_at: string; + updated_at: string; + is_followup: boolean; + phases: { + context: PRPhaseLog; + analysis: PRPhaseLog; + synthesis: PRPhaseLog; + }; +} + /** * Creates the GitHub Integration API implementation */ @@ -564,6 +619,10 @@ export const createGitHubAPI = (): GitHubAPI => ({ runFollowupReview: (projectId: string, prNumber: number): void => sendIpc(IPC_CHANNELS.GITHUB_PR_FOLLOWUP_REVIEW, projectId, prNumber), + // PR logs + getPRLogs: (projectId: string, prNumber: number): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_GET_LOGS, projectId, prNumber), + // PR event listeners onPRReviewProgress: ( callback: (projectId: string, progress: PRReviewProgress) => void diff --git a/apps/frontend/src/preload/api/modules/gitlab-api.ts b/apps/frontend/src/preload/api/modules/gitlab-api.ts index 06dcbba6..ad3f58b8 100644 --- a/apps/frontend/src/preload/api/modules/gitlab-api.ts +++ b/apps/frontend/src/preload/api/modules/gitlab-api.ts @@ -149,6 +149,7 @@ export interface GitLabAPI { // OAuth operations (glab CLI) checkGitLabCli: () => Promise>; + installGitLabCli: () => Promise>; checkGitLabAuth: (instanceUrl?: string) => Promise>; startGitLabAuth: (instanceUrl?: string) => Promise>; getGitLabToken: (instanceUrl?: string) => Promise>; @@ -397,6 +398,9 @@ export const createGitLabAPI = (): GitLabAPI => ({ checkGitLabCli: (): Promise> => invokeIpc(IPC_CHANNELS.GITLAB_CHECK_CLI), + installGitLabCli: (): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_INSTALL_CLI), + checkGitLabAuth: (instanceUrl?: string): Promise> => invokeIpc(IPC_CHANNELS.GITLAB_CHECK_AUTH, instanceUrl), diff --git a/apps/frontend/src/preload/api/modules/mcp-api.ts b/apps/frontend/src/preload/api/modules/mcp-api.ts new file mode 100644 index 00000000..91a951b7 --- /dev/null +++ b/apps/frontend/src/preload/api/modules/mcp-api.ts @@ -0,0 +1,27 @@ +/** + * MCP Server API + * + * Exposes MCP health check and connection test functionality to the renderer. + */ + +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../../shared/constants/ipc'; +import type { IPCResult } from '../../../shared/types/common'; +import type { CustomMcpServer, McpHealthCheckResult, McpTestConnectionResult } from '../../../shared/types/project'; + +export interface McpAPI { + /** Quick health check for a custom MCP server */ + checkMcpHealth: (server: CustomMcpServer) => Promise>; + /** Full MCP connection test */ + testMcpConnection: (server: CustomMcpServer) => Promise>; +} + +export function createMcpAPI(): McpAPI { + return { + checkMcpHealth: (server: CustomMcpServer) => + ipcRenderer.invoke(IPC_CHANNELS.MCP_CHECK_HEALTH, server), + + testMcpConnection: (server: CustomMcpServer) => + ipcRenderer.invoke(IPC_CHANNELS.MCP_TEST_CONNECTION, server), + }; +} diff --git a/apps/frontend/src/renderer/components/AgentTools.tsx b/apps/frontend/src/renderer/components/AgentTools.tsx index 6e9eb5d8..69ad85d3 100644 --- a/apps/frontend/src/renderer/components/AgentTools.tsx +++ b/apps/frontend/src/renderer/components/AgentTools.tsx @@ -3,6 +3,7 @@ * * Displays MCP server and tool configuration for each agent phase. * Helps users understand what tools are available during different execution phases. + * Now shows per-project MCP configuration with toggles to enable/disable servers. */ import { @@ -20,11 +21,36 @@ import { Monitor, Globe, ClipboardList, - ListChecks + ListChecks, + Info, + AlertCircle, + Plus, + X, + RotateCcw, + Pencil, + Trash2, + Terminal, + Loader2, + RefreshCw, + AlertTriangle, + Lock } from 'lucide-react'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { ScrollArea } from './ui/scroll-area'; +import { Switch } from './ui/switch'; +import { Button } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from './ui/dialog'; import { useSettingsStore } from '../stores/settings-store'; +import { useProjectStore } from '../stores/project-store'; +import type { ProjectEnvConfig, AgentMcpOverrides, AgentMcpOverride, CustomMcpServer, McpHealthCheckResult, McpHealthStatus } from '../../shared/types'; +import { CustomMcpDialog } from './CustomMcpDialog'; +import { useTranslation } from 'react-i18next'; import { DEFAULT_PHASE_MODELS, DEFAULT_PHASE_THINKING, @@ -50,7 +76,7 @@ interface AgentConfig { phase: 'spec' | 'planning' | 'coding' | 'qa'; } | { type: 'feature'; - feature: 'insights' | 'ideation' | 'roadmap' | 'githubIssues' | 'githubPrs'; + feature: 'insights' | 'ideation' | 'roadmap' | 'githubIssues' | 'githubPrs' | 'utility'; } | { type: 'fixed'; // For agents not yet configurable model: ModelTypeShort; @@ -184,8 +210,7 @@ const AGENT_CONFIGS: Record = { category: 'utility', tools: [], mcp_servers: [], - // Commit message uses Haiku for speed - not yet user-configurable - settingsSource: { type: 'fixed', model: 'haiku', thinking: 'low' }, + settingsSource: { type: 'feature', feature: 'utility' }, }, merge_resolver: { label: 'Merge Resolver', @@ -193,8 +218,7 @@ const AGENT_CONFIGS: Record = { category: 'utility', tools: [], mcp_servers: [], - // Merge resolver uses Haiku - not yet user-configurable - settingsSource: { type: 'fixed', model: 'haiku', thinking: 'low' }, + settingsSource: { type: 'feature', feature: 'utility' }, }, insights: { label: 'Insights', @@ -316,6 +340,16 @@ const MCP_SERVERS: Record void; + onRemoveMcp: (agentId: string, mcpId: string) => void; } -function AgentCard({ config, modelLabel, thinkingLabel }: AgentCardProps) { +function AgentCard({ id, config, modelLabel, thinkingLabel, overrides, mcpServerStates, customServers, onAddMcp, onRemoveMcp }: AgentCardProps) { const [isExpanded, setIsExpanded] = useState(false); + const [showAddDialog, setShowAddDialog] = useState(false); + const { t } = useTranslation(['settings']); const category = CATEGORIES[config.category as keyof typeof CATEGORIES]; const CategoryIcon = category.icon; + // Build combined MCP server info including custom servers + const allMcpServers = useMemo(() => { + const servers = { ...MCP_SERVERS }; + for (const custom of customServers) { + servers[custom.id] = { + name: custom.name, + description: custom.description || (custom.type === 'command' ? `${custom.command} ${custom.args?.join(' ') || ''}` : custom.url || ''), + icon: custom.type === 'command' ? Terminal : Globe, + }; + } + return servers; + }, [customServers]); + + // Calculate effective MCPs: defaults + adds - removes, then filter by project-level MCP states + const effectiveMcps = useMemo(() => { + const defaultMcps = [...config.mcp_servers, ...(config.mcp_optional || [])]; + const added = overrides?.add || []; + const removed = overrides?.remove || []; + const combinedMcps = [...new Set([...defaultMcps, ...added])].filter(mcp => !removed.includes(mcp)); + + // Filter out MCPs that are disabled at project level (custom servers are always enabled) + return combinedMcps.filter(mcp => { + if (!mcpServerStates) return true; // No project config, show all + // Custom servers are always available if they exist + if (customServers.some(s => s.id === mcp)) return true; + switch (mcp) { + case 'context7': return mcpServerStates.context7Enabled !== false; + case 'graphiti-memory': return mcpServerStates.graphitiEnabled !== false; + case 'linear': return mcpServerStates.linearMcpEnabled !== false; + case 'electron': return mcpServerStates.electronEnabled !== false; + case 'puppeteer': return mcpServerStates.puppeteerEnabled !== false; + default: return true; + } + }); + }, [config, overrides, mcpServerStates, customServers]); + + // Check if an MCP is a custom addition (not in defaults) + const isCustomAdd = (mcpId: string) => { + const defaults = [...config.mcp_servers, ...(config.mcp_optional || [])]; + return !defaults.includes(mcpId) && (overrides?.add || []).includes(mcpId); + }; + + // Get removed MCPs (from defaults) + const removedMcps = useMemo(() => { + const defaults = [...config.mcp_servers, ...(config.mcp_optional || [])]; + return defaults.filter(mcp => (overrides?.remove || []).includes(mcp)); + }, [config, overrides]); + + // Get MCPs that can be added (not already in effective list) - includes custom servers + const customServerIds = customServers.map(s => s.id); + const allAvailableMcpIds = [...ALL_MCP_SERVERS, ...customServerIds]; + const availableMcps = allAvailableMcpIds.filter( + mcp => !effectiveMcps.includes(mcp) && !removedMcps.includes(mcp) && mcp !== 'auto-claude' + ); + return (
{/* Header - clickable to expand */}
- {config.mcp_servers.length + (config.mcp_optional?.length || 0)} MCP + {effectiveMcps.length} MCP {isExpanded ? ( @@ -376,61 +473,85 @@ function AgentCard({ config, modelLabel, thinkingLabel }: AgentCardProps) {
{/* MCP Servers */}
-

- MCP Servers -

- {config.mcp_servers.length > 0 || (config.mcp_optional && config.mcp_optional.length > 0) ? ( -
- {config.mcp_servers.map((server) => { - const serverInfo = MCP_SERVERS[server]; +
+

+ MCP Servers +

+ {availableMcps.length > 0 && ( + + )} +
+ {effectiveMcps.length > 0 || removedMcps.length > 0 ? ( +
+ {/* Active MCPs */} + {effectiveMcps.map((server) => { + const serverInfo = allMcpServers[server]; const ServerIcon = serverInfo?.icon || Server; + const isAdded = isCustomAdd(server); + const canRemove = server !== 'auto-claude'; + return ( -
+
{serverInfo?.name || server} + {isAdded && ( + + {t('mcp.added')} + + )}
-

- {serverInfo?.description} -

- {serverInfo?.tools && ( -
- {serverInfo.tools.slice(0, 3).map((tool) => ( - - {tool.replace('mcp__', '').replace(/__/g, ':')} - - ))} - {serverInfo.tools.length > 3 && ( - - +{serverInfo.tools.length - 3} more - - )} -
+ {canRemove && ( + )}
); })} - {config.mcp_optional?.map((server) => { - const serverInfo = MCP_SERVERS[server]; + + {/* Removed MCPs (grayed out with restore option) */} + {removedMcps.map((server) => { + const serverInfo = allMcpServers[server]; const ServerIcon = serverInfo?.icon || Server; + return ( -
-
+
+
{serverInfo?.name || server} - (conditional) + + ({t('mcp.removed')}) +
-

- {serverInfo?.description} -

+
); })}
) : ( -

No MCP servers required

+

{t('mcp.noMcpServers')}

)}
@@ -456,15 +577,399 @@ function AgentCard({ config, modelLabel, thinkingLabel }: AgentCardProps) {
)} + + {/* Add MCP Dialog */} + + + + {t('mcp.addMcpTo', { agent: config.label })} + {t('mcp.addMcpDescription')} + +
+ {availableMcps.length > 0 ? ( + availableMcps.map((mcpId) => { + const server = allMcpServers[mcpId]; + const ServerIcon = server?.icon || Server; + return ( + + ); + }) + ) : ( +

+ {t('mcp.allMcpsAdded')} +

+ )} + {/* Also show removed MCPs that can be restored */} + {removedMcps.length > 0 && ( + <> +
+

{t('mcp.restore')}:

+
+ {removedMcps.map((mcpId) => { + const server = allMcpServers[mcpId]; + const ServerIcon = server?.icon || Server; + return ( + + ); + })} + + )} +
+
+
); } export function AgentTools() { + const { t } = useTranslation(['settings']); const settings = useSettingsStore((state) => state.settings); + const projects = useProjectStore((state) => state.projects); + const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + const selectedProject = projects.find((p) => p.id === selectedProjectId); + const [expandedCategories, setExpandedCategories] = useState>( new Set(['spec', 'build', 'qa']) ); + const [envConfig, setEnvConfig] = useState(null); + const [, setIsLoading] = useState(false); + + // Custom MCP server dialog state + const [showCustomMcpDialog, setShowCustomMcpDialog] = useState(false); + const [editingCustomServer, setEditingCustomServer] = useState(null); + + // Health status tracking for custom servers + const [serverHealthStatus, setServerHealthStatus] = useState>({}); + const [testingServers, setTestingServers] = useState>(new Set()); + + // Load project env config when project changes + useEffect(() => { + if (selectedProjectId && selectedProject?.autoBuildPath) { + setIsLoading(true); + window.electronAPI.getProjectEnv(selectedProjectId) + .then((result) => { + if (result.success && result.data) { + setEnvConfig(result.data); + } else { + setEnvConfig(null); + } + }) + .catch(() => { + setEnvConfig(null); + }) + .finally(() => { + setIsLoading(false); + }); + } else { + setEnvConfig(null); + } + }, [selectedProjectId, selectedProject?.autoBuildPath]); + + // Update MCP server toggle + const updateMcpServer = useCallback(async ( + key: keyof NonNullable, + value: boolean + ) => { + if (!selectedProjectId || !envConfig) return; + + const newMcpServers = { + ...envConfig.mcpServers, + [key]: value, + }; + + // Optimistic update + setEnvConfig((prev) => prev ? { ...prev, mcpServers: newMcpServers } : null); + + // Save to backend + try { + await window.electronAPI.updateProjectEnv(selectedProjectId, { + mcpServers: newMcpServers, + }); + } catch (error) { + // Revert on error + console.error('Failed to update MCP config:', error); + setEnvConfig((prev) => prev ? { ...prev, mcpServers: envConfig.mcpServers } : null); + } + }, [selectedProjectId, envConfig]); + + // Handle adding an MCP to an agent + const handleAddMcp = useCallback(async (agentId: string, mcpId: string) => { + if (!selectedProjectId || !envConfig) return; + + const currentOverrides = envConfig.agentMcpOverrides || {}; + const agentOverride = currentOverrides[agentId] || {}; + + // If it's in the remove list, take it out (restore) + // Otherwise, add it to the add list + let newOverride: AgentMcpOverride; + if (agentOverride.remove?.includes(mcpId)) { + newOverride = { + ...agentOverride, + remove: agentOverride.remove.filter(m => m !== mcpId), + }; + } else { + newOverride = { + ...agentOverride, + add: [...(agentOverride.add || []), mcpId].filter((v, i, a) => a.indexOf(v) === i), + }; + } + + // Clean up empty arrays + if (newOverride.add?.length === 0) delete newOverride.add; + if (newOverride.remove?.length === 0) delete newOverride.remove; + + const newOverrides = { ...currentOverrides }; + if (Object.keys(newOverride).length === 0) { + delete newOverrides[agentId]; + } else { + newOverrides[agentId] = newOverride; + } + + // Optimistic update + setEnvConfig((prev) => prev ? { ...prev, agentMcpOverrides: newOverrides } : null); + + // Save to backend + try { + await window.electronAPI.updateProjectEnv(selectedProjectId, { + agentMcpOverrides: newOverrides, + }); + } catch (error) { + console.error('Failed to update agent MCP config:', error); + setEnvConfig((prev) => prev ? { ...prev, agentMcpOverrides: currentOverrides } : null); + } + }, [selectedProjectId, envConfig]); + + // Handle removing an MCP from an agent + const handleRemoveMcp = useCallback(async (agentId: string, mcpId: string) => { + if (!selectedProjectId || !envConfig) return; + + const agentConfig = AGENT_CONFIGS[agentId]; + const defaults = [...(agentConfig?.mcp_servers || []), ...(agentConfig?.mcp_optional || [])]; + const isDefault = defaults.includes(mcpId); + + const currentOverrides = envConfig.agentMcpOverrides || {}; + const agentOverride = currentOverrides[agentId] || {}; + + let newOverride: AgentMcpOverride; + if (isDefault) { + // It's a default MCP - add to remove list + newOverride = { + ...agentOverride, + remove: [...(agentOverride.remove || []), mcpId].filter((v, i, a) => a.indexOf(v) === i), + }; + } else { + // It's a custom addition - remove from add list + newOverride = { + ...agentOverride, + add: (agentOverride.add || []).filter(m => m !== mcpId), + }; + } + + // Clean up empty arrays + if (newOverride.add?.length === 0) delete newOverride.add; + if (newOverride.remove?.length === 0) delete newOverride.remove; + + const newOverrides = { ...currentOverrides }; + if (Object.keys(newOverride).length === 0) { + delete newOverrides[agentId]; + } else { + newOverrides[agentId] = newOverride; + } + + // Optimistic update + setEnvConfig((prev) => prev ? { ...prev, agentMcpOverrides: newOverrides } : null); + + // Save to backend + try { + await window.electronAPI.updateProjectEnv(selectedProjectId, { + agentMcpOverrides: newOverrides, + }); + } catch (error) { + console.error('Failed to update agent MCP config:', error); + setEnvConfig((prev) => prev ? { ...prev, agentMcpOverrides: currentOverrides } : null); + } + }, [selectedProjectId, envConfig]); + + // Handle saving a custom MCP server + const handleSaveCustomServer = useCallback(async (server: CustomMcpServer) => { + if (!selectedProjectId || !envConfig) return; + + const currentServers = envConfig.customMcpServers || []; + const existingIndex = currentServers.findIndex(s => s.id === server.id); + + let newServers: CustomMcpServer[]; + if (existingIndex >= 0) { + // Update existing + newServers = [...currentServers]; + newServers[existingIndex] = server; + } else { + // Add new + newServers = [...currentServers, server]; + } + + // Optimistic update + setEnvConfig((prev) => prev ? { ...prev, customMcpServers: newServers } : null); + + // Save to backend + try { + await window.electronAPI.updateProjectEnv(selectedProjectId, { + customMcpServers: newServers, + }); + } catch (error) { + console.error('Failed to save custom MCP server:', error); + setEnvConfig((prev) => prev ? { ...prev, customMcpServers: currentServers } : null); + } + }, [selectedProjectId, envConfig]); + + // Handle deleting a custom MCP server + const handleDeleteCustomServer = useCallback(async (serverId: string) => { + if (!selectedProjectId || !envConfig) return; + + const currentServers = envConfig.customMcpServers || []; + const newServers = currentServers.filter(s => s.id !== serverId); + + // Also remove from any agent overrides that reference it + const currentOverrides = envConfig.agentMcpOverrides || {}; + const newOverrides = { ...currentOverrides }; + for (const agentId of Object.keys(newOverrides)) { + const override = newOverrides[agentId]; + if (override.add?.includes(serverId)) { + newOverrides[agentId] = { + ...override, + add: override.add.filter(m => m !== serverId), + }; + if (newOverrides[agentId].add?.length === 0) { + delete newOverrides[agentId].add; + } + if (Object.keys(newOverrides[agentId]).length === 0) { + delete newOverrides[agentId]; + } + } + } + + // Optimistic update + setEnvConfig((prev) => prev ? { + ...prev, + customMcpServers: newServers, + agentMcpOverrides: newOverrides, + } : null); + + // Save to backend + try { + await window.electronAPI.updateProjectEnv(selectedProjectId, { + customMcpServers: newServers, + agentMcpOverrides: newOverrides, + }); + } catch (error) { + console.error('Failed to delete custom MCP server:', error); + setEnvConfig((prev) => prev ? { ...prev, customMcpServers: currentServers, agentMcpOverrides: currentOverrides } : null); + } + }, [selectedProjectId, envConfig]); + + // Check health of all custom MCP servers + const checkAllServersHealth = useCallback(async () => { + const servers = envConfig?.customMcpServers || []; + if (servers.length === 0) return; + + for (const server of servers) { + // Set checking status + setServerHealthStatus(prev => ({ + ...prev, + [server.id]: { + serverId: server.id, + status: 'checking', + checkedAt: new Date().toISOString(), + } + })); + + try { + const result = await window.electronAPI.checkMcpHealth(server); + if (result.success && result.data) { + setServerHealthStatus(prev => ({ + ...prev, + [server.id]: result.data!, + })); + } + } catch (error) { + setServerHealthStatus(prev => ({ + ...prev, + [server.id]: { + serverId: server.id, + status: 'unknown', + message: 'Health check failed', + checkedAt: new Date().toISOString(), + } + })); + } + } + }, [envConfig?.customMcpServers]); + + // Check health when custom servers change + useEffect(() => { + if (envConfig?.customMcpServers && envConfig.customMcpServers.length > 0) { + checkAllServersHealth(); + } + }, [envConfig?.customMcpServers, checkAllServersHealth]); + + // Test a single server connection (full test) + const handleTestConnection = useCallback(async (server: CustomMcpServer) => { + setTestingServers(prev => new Set(prev).add(server.id)); + + try { + const result = await window.electronAPI.testMcpConnection(server); + if (result.success && result.data) { + // Update health status based on test result + setServerHealthStatus(prev => ({ + ...prev, + [server.id]: { + serverId: server.id, + status: result.data!.success ? 'healthy' : 'unhealthy', + message: result.data!.message, + responseTime: result.data!.responseTime, + checkedAt: new Date().toISOString(), + } + })); + } + } catch (error) { + setServerHealthStatus(prev => ({ + ...prev, + [server.id]: { + serverId: server.id, + status: 'unhealthy', + message: 'Connection test failed', + checkedAt: new Date().toISOString(), + } + })); + } finally { + setTestingServers(prev => { + const next = new Set(prev); + next.delete(server.id); + return next; + }); + } + }, []); // Get phase and feature settings with defaults const phaseModels = settings.customPhaseModels || DEFAULT_PHASE_MODELS; @@ -472,6 +977,19 @@ export function AgentTools() { const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS; const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING; + // Get MCP server states for display + const mcpServers = envConfig?.mcpServers || {}; + + // Count enabled MCP servers + const enabledCount = [ + mcpServers.context7Enabled !== false, + mcpServers.graphitiEnabled && envConfig?.graphitiProviderConfig, + mcpServers.linearMcpEnabled !== false && envConfig?.linearEnabled, + mcpServers.electronEnabled, + mcpServers.puppeteerEnabled, + true, // auto-claude always enabled + ].filter(Boolean).length; + // Resolve model and thinking for an agent based on its settings source const resolveAgentSettings = useMemo(() => { return (config: AgentConfig): { model: ModelTypeShort; thinking: ThinkingLevel } => { @@ -530,41 +1048,294 @@ export function AgentTools() {
-
-

MCP Server Overview

+
+
+

MCP Server Overview

+ {selectedProject && ( + + for {selectedProject.name} + + )} +

- View which MCP servers and tools are available for each agent phase + {selectedProject + ? t('settings:mcp.description') + : t('settings:mcp.descriptionNoProject')}

+ {envConfig && ( +
+ {t('settings:mcp.serversEnabled', { count: enabledCount })} +
+ )}
{/* Content */}
- {/* MCP Server Legend */} -
-

Available MCP Servers

-
- {Object.entries(MCP_SERVERS).map(([id, server]) => { - const Icon = server.icon; - return ( -
-
- - {server.name} -
-

{server.description}

- {server.tools && ( -
- {server.tools.length} tools available -
- )} -
- ); - })} + {/* No project selected message */} + {!selectedProject && ( +
+ +

{t('settings:mcp.noProjectSelected')}

+

+ {t('settings:mcp.noProjectSelectedDescription')} +

-
+ )} + + {/* Project not initialized message */} + {selectedProject && !selectedProject.autoBuildPath && ( +
+ +

{t('settings:mcp.projectNotInitialized')}

+

+ {t('settings:mcp.projectNotInitializedDescription')} +

+
+ )} + + {/* MCP Server Configuration */} + {envConfig && ( +
+
+

{t('settings:mcp.configuration')}

+ + {t('settings:mcp.configurationHint')} + +
+ +
+ {/* Context7 */} +
+
+ +
+ {t('settings:mcp.servers.context7.name')} +

{t('settings:mcp.servers.context7.description')}

+
+
+ updateMcpServer('context7Enabled', checked)} + /> +
+ + {/* Graphiti Memory */} +
+
+ +
+ {t('settings:mcp.servers.graphiti.name')} +

+ {envConfig.graphitiProviderConfig + ? t('settings:mcp.servers.graphiti.description') + : t('settings:mcp.servers.graphiti.notConfigured')} +

+
+
+ updateMcpServer('graphitiEnabled', checked)} + disabled={!envConfig.graphitiProviderConfig} + /> +
+ + {/* Linear */} +
+
+ +
+ {t('settings:mcp.servers.linear.name')} +

+ {envConfig.linearEnabled + ? t('settings:mcp.servers.linear.description') + : t('settings:mcp.servers.linear.notConfigured')} +

+
+
+ updateMcpServer('linearMcpEnabled', checked)} + disabled={!envConfig.linearEnabled} + /> +
+ + {/* Browser Automation Section */} +
+
+ + + {t('settings:mcp.browserAutomation')} + +
+ + {/* Electron */} +
+
+ +
+ {t('settings:mcp.servers.electron.name')} +

{t('settings:mcp.servers.electron.description')}

+
+
+ updateMcpServer('electronEnabled', checked)} + /> +
+ + {/* Puppeteer */} +
+
+ +
+ {t('settings:mcp.servers.puppeteer.name')} +

{t('settings:mcp.servers.puppeteer.description')}

+
+
+ updateMcpServer('puppeteerEnabled', checked)} + /> +
+
+ + {/* Auto-Claude (always enabled) */} +
+
+ +
+ {t('settings:mcp.servers.autoClaude.name')} +

{t('settings:mcp.servers.autoClaude.description')} ({t('settings:mcp.alwaysEnabled')})

+
+
+ +
+ + {/* Custom MCP Servers Section */} +
+
+
+ + + {t('settings:mcp.customServers')} + +
+ +
+ + {(envConfig.customMcpServers?.length ?? 0) > 0 ? ( +
+ {envConfig.customMcpServers?.map((server) => { + const health = serverHealthStatus[server.id]; + const isTesting = testingServers.has(server.id); + const isChecking = health?.status === 'checking'; + + // Status indicator component + const StatusIndicator = () => { + if (isTesting || isChecking) { + return ; + } + switch (health?.status) { + case 'healthy': + return ; + case 'needs_auth': + return ; + case 'unhealthy': + return ; + default: + return ; + } + }; + + return ( +
+
+ {/* Status indicator */} + + {server.type === 'command' ? ( + + ) : ( + + )} +
+
+ {server.name} + {health?.responseTime && ( + + {health.responseTime}ms + + )} +
+

+ {health?.message || (server.type === 'command' + ? `${server.command} ${server.args?.join(' ') || ''}` + : server.url)} +

+
+
+
+ {/* Test button - always visible */} + + {/* Edit/Delete - show on hover */} +
+ + +
+
+
+ ); + })} +
+ ) : ( +

+ {t('settings:mcp.noCustomServers')} +

+ )} +
+
+
+ )} {/* Agent Categories */} {Object.entries(CATEGORIES).map(([categoryId, category]) => { @@ -578,6 +1349,7 @@ export function AgentTools() {
{/* Category Header */}
+ + {/* Custom MCP Server Dialog */} + s.id)} + onSave={handleSaveCustomServer} + />
); } diff --git a/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx b/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx new file mode 100644 index 00000000..06744006 --- /dev/null +++ b/apps/frontend/src/renderer/components/ClaudeCodeStatusBadge.tsx @@ -0,0 +1,327 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Terminal, Check, AlertTriangle, X, Loader2, Download, RefreshCw, ExternalLink } from 'lucide-react'; +import { Button } from './ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from './ui/popover'; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from './ui/tooltip'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from './ui/alert-dialog'; +import { cn } from '../lib/utils'; +import type { ClaudeCodeVersionInfo } from '../../shared/types/cli'; + +interface ClaudeCodeStatusBadgeProps { + className?: string; +} + +type StatusType = 'loading' | 'installed' | 'outdated' | 'not-found' | 'error'; + +// Check every 24 hours +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** + * Claude Code CLI status badge for the sidebar. + * Shows installation status and provides quick access to install/update. + */ +export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps) { + const { t } = useTranslation(['common', 'navigation']); + const [status, setStatus] = useState('loading'); + const [versionInfo, setVersionInfo] = useState(null); + const [isInstalling, setIsInstalling] = useState(false); + const [lastChecked, setLastChecked] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [showUpdateWarning, setShowUpdateWarning] = useState(false); + + // Check Claude Code version + const checkVersion = useCallback(async () => { + try { + if (!window.electronAPI?.checkClaudeCodeVersion) { + setStatus('error'); + return; + } + + const result = await window.electronAPI.checkClaudeCodeVersion(); + + if (result.success && result.data) { + setVersionInfo(result.data); + setLastChecked(new Date()); + + if (!result.data.installed) { + setStatus('not-found'); + } else if (result.data.isOutdated) { + setStatus('outdated'); + } else { + setStatus('installed'); + } + } else { + setStatus('error'); + } + } catch (err) { + console.error('Failed to check Claude Code version:', err); + setStatus('error'); + } + }, []); + + // Initial check and periodic re-check + useEffect(() => { + checkVersion(); + + // Set up periodic check + const interval = setInterval(() => { + checkVersion(); + }, CHECK_INTERVAL_MS); + + return () => clearInterval(interval); + }, [checkVersion]); + + // Perform the actual install/update + const performInstall = async () => { + setIsInstalling(true); + setShowUpdateWarning(false); + try { + if (!window.electronAPI?.installClaudeCode) { + return; + } + + const result = await window.electronAPI.installClaudeCode(); + + if (result.success) { + // Re-check after a delay + setTimeout(() => { + checkVersion(); + }, 5000); + } + } catch (err) { + console.error('Failed to install Claude Code:', err); + } finally { + setIsInstalling(false); + } + }; + + // Handle install/update button click + const handleInstall = () => { + if (status === 'outdated') { + // Show warning for updates since it will close running Claude sessions + setShowUpdateWarning(true); + } else { + // Fresh install - no warning needed + performInstall(); + } + }; + + // Get status indicator color + const getStatusColor = () => { + switch (status) { + case 'installed': + return 'bg-green-500'; + case 'outdated': + return 'bg-yellow-500'; + case 'not-found': + case 'error': + return 'bg-destructive'; + default: + return 'bg-muted-foreground'; + } + }; + + // Get status icon + const getStatusIcon = () => { + switch (status) { + case 'loading': + return ; + case 'installed': + return ; + case 'outdated': + return ; + case 'not-found': + return ; + case 'error': + return ; + } + }; + + // Get tooltip text + const getTooltipText = () => { + switch (status) { + case 'loading': + return t('navigation:claudeCode.checking', 'Checking Claude Code...'); + case 'installed': + return t('navigation:claudeCode.upToDate', 'Claude Code is up to date'); + case 'outdated': + return t('navigation:claudeCode.updateAvailable', 'Claude Code update available'); + case 'not-found': + return t('navigation:claudeCode.notInstalled', 'Claude Code not installed'); + case 'error': + return t('navigation:claudeCode.error', 'Error checking Claude Code'); + } + }; + + return ( + + + + + + + + + {getTooltipText()} + + + + +
+ {/* Header */} +
+
+ +
+
+

Claude Code CLI

+

+ {getStatusIcon()} + {status === 'installed' && t('navigation:claudeCode.installed', 'Installed')} + {status === 'outdated' && t('navigation:claudeCode.outdated', 'Update available')} + {status === 'not-found' && t('navigation:claudeCode.missing', 'Not installed')} + {status === 'loading' && t('navigation:claudeCode.checking', 'Checking...')} + {status === 'error' && t('navigation:claudeCode.error', 'Error')} +

+
+
+ + {/* Version info */} + {versionInfo && status !== 'loading' && ( +
+ {versionInfo.installed && ( +
+ {t('navigation:claudeCode.current', 'Current')}: + {versionInfo.installed} +
+ )} + {versionInfo.latest && versionInfo.latest !== 'unknown' && ( +
+ {t('navigation:claudeCode.latest', 'Latest')}: + {versionInfo.latest} +
+ )} + {lastChecked && ( +
+ {t('navigation:claudeCode.lastChecked', 'Last checked')}: + {lastChecked.toLocaleTimeString()} +
+ )} +
+ )} + + {/* Actions */} +
+ {(status === 'not-found' || status === 'outdated') && ( + + )} + +
+ + {/* Learn more link */} + +
+
+ + {/* Update warning dialog */} + + + + + {t('navigation:claudeCode.updateWarningTitle', 'Update Claude Code?')} + + + {t('navigation:claudeCode.updateWarningDescription', 'Updating will close all running Claude Code sessions. Any unsaved work in those sessions may be lost. Make sure to save your work before proceeding.')} + + + + + {t('common:cancel', 'Cancel')} + + + {t('navigation:claudeCode.updateAnyway', 'Update Anyway')} + + + + +
+ ); +} diff --git a/apps/frontend/src/renderer/components/CustomMcpDialog.tsx b/apps/frontend/src/renderer/components/CustomMcpDialog.tsx new file mode 100644 index 00000000..c9198898 --- /dev/null +++ b/apps/frontend/src/renderer/components/CustomMcpDialog.tsx @@ -0,0 +1,472 @@ +/** + * Custom MCP Server Dialog + * + * Dialog for adding/editing custom MCP servers. + * Supports both command-based (npx/npm) and HTTP-based servers. + */ + +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { RadioGroup, RadioGroupItem } from './ui/radio-group'; +import { useTranslation } from 'react-i18next'; +import type { CustomMcpServer } from '../../shared/types'; +import { Terminal, Globe, X, Github, Loader2, ExternalLink } from 'lucide-react'; + +interface CustomMcpDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + server: CustomMcpServer | null; // null = create new, non-null = edit + existingIds: string[]; // Existing server IDs for validation + onSave: (server: CustomMcpServer) => void; +} + +export function CustomMcpDialog({ + open, + onOpenChange, + server, + existingIds, + onSave +}: CustomMcpDialogProps) { + const { t } = useTranslation(['settings', 'common']); + const isEditing = server !== null; + + const [formData, setFormData] = useState({ + id: '', + name: '', + type: 'command', + command: 'npx', + args: [], + url: '', + headers: {}, + description: '', + }); + + const [argsInput, setArgsInput] = useState(''); + const [headerKey, setHeaderKey] = useState(''); + const [headerValue, setHeaderValue] = useState(''); + const [bearerToken, setBearerToken] = useState(''); + const [showAdvancedHeaders, setShowAdvancedHeaders] = useState(false); + const [error, setError] = useState(null); + + // Known provider patterns for helpful hints + const urlHint = useMemo(() => { + const url = formData.url?.toLowerCase() || ''; + if (url.includes('github')) { + return { + provider: 'GitHub', + icon: Github, + message: t('mcp.hints.github'), + link: 'https://github.com/settings/tokens', + linkText: t('mcp.hints.createGithubPat'), + }; + } + if (url.includes('google') || url.includes('googleapis')) { + return { + provider: 'Google', + icon: Globe, + message: t('mcp.hints.google'), + link: 'https://console.cloud.google.com/apis/credentials', + linkText: t('mcp.hints.createGoogleToken'), + }; + } + if (url.includes('anthropic')) { + return { + provider: 'Anthropic', + icon: Globe, + message: t('mcp.hints.anthropic'), + link: 'https://console.anthropic.com/settings/keys', + linkText: t('mcp.hints.createAnthropicKey'), + }; + } + if (url.includes('openai')) { + return { + provider: 'OpenAI', + icon: Globe, + message: t('mcp.hints.openai'), + link: 'https://platform.openai.com/api-keys', + linkText: t('mcp.hints.createOpenaiKey'), + }; + } + return null; + }, [formData.url, t]); + + // Reset form when dialog opens/closes or server changes + useEffect(() => { + if (open && server) { + setFormData(server); + setArgsInput(server.args?.join(' ') || ''); + // Extract bearer token from existing Authorization header + const authHeader = server.headers?.['Authorization'] || server.headers?.['authorization'] || ''; + if (authHeader.toLowerCase().startsWith('bearer ')) { + setBearerToken(authHeader.substring(7)); + } else { + setBearerToken(''); + } + // Show advanced headers if there are non-Authorization headers + const hasOtherHeaders = Object.keys(server.headers || {}).some( + k => k.toLowerCase() !== 'authorization' + ); + setShowAdvancedHeaders(hasOtherHeaders); + setError(null); + } else if (open) { + setFormData({ + id: '', + name: '', + type: 'command', + command: 'npx', + args: [], + url: '', + headers: {}, + description: '', + }); + setArgsInput(''); + setBearerToken(''); + setShowAdvancedHeaders(false); + setError(null); + } + setHeaderKey(''); + setHeaderValue(''); + }, [open, server]); + + // Generate ID from name + const generateId = (name: string): string => { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + }; + + const handleSave = () => { + // Validate + if (!formData.name.trim()) { + setError(t('mcp.errorNameRequired')); + return; + } + + const generatedId = isEditing ? formData.id : generateId(formData.name); + + // Check for duplicate ID (only when creating new) + if (!isEditing && existingIds.includes(generatedId)) { + setError(t('mcp.errorIdExists')); + return; + } + + if (formData.type === 'command' && !formData.command?.trim()) { + setError(t('mcp.errorCommandRequired')); + return; + } + + if (formData.type === 'http' && !formData.url?.trim()) { + setError(t('mcp.errorUrlRequired')); + return; + } + + // Build headers, merging bearer token if provided + const finalHeaders: Record = {}; + if (formData.type === 'http') { + // Start with existing headers (excluding old Authorization if we have a new bearer token) + if (formData.headers) { + for (const [key, value] of Object.entries(formData.headers)) { + if (bearerToken && key.toLowerCase() === 'authorization') { + continue; // Skip old auth header if we have a new bearer token + } + finalHeaders[key] = value; + } + } + // Add bearer token as Authorization header + if (bearerToken.trim()) { + finalHeaders['Authorization'] = `Bearer ${bearerToken.trim()}`; + } + } + + const serverToSave: CustomMcpServer = { + id: generatedId, + name: formData.name.trim(), + type: formData.type, + description: formData.description?.trim() || undefined, + ...(formData.type === 'command' + ? { + command: formData.command, + args: argsInput.split(' ').filter(Boolean), + } + : { + url: formData.url, + headers: Object.keys(finalHeaders).length > 0 ? finalHeaders : undefined, + }), + }; + + onSave(serverToSave); + onOpenChange(false); + }; + + const addHeader = () => { + if (headerKey.trim() && headerValue.trim()) { + setFormData(prev => ({ + ...prev, + headers: { ...prev.headers, [headerKey.trim()]: headerValue.trim() }, + })); + setHeaderKey(''); + setHeaderValue(''); + } + }; + + const removeHeader = (key: string) => { + setFormData(prev => { + const newHeaders = { ...prev.headers }; + delete newHeaders[key]; + return { ...prev, headers: newHeaders }; + }); + }; + + const isValid = formData.name.trim() && ( + (formData.type === 'command' && formData.command?.trim()) || + (formData.type === 'http' && formData.url?.trim()) + ); + + return ( + + + + + {isEditing ? t('mcp.editCustomServer') : t('mcp.addCustomServer')} + + + {t('mcp.customServerDescription')} + + + +
+ {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Server Type */} +
+ + + setFormData(prev => ({ ...prev, type: value })) + } + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
+ + {/* Name */} +
+ + { + setFormData(prev => ({ ...prev, name: e.target.value })); + setError(null); + }} + placeholder={t('mcp.serverNamePlaceholder')} + /> + {!isEditing && formData.name && ( +

+ ID: {generateId(formData.name) || '...'} +

+ )} +
+ + {/* Description (optional) */} +
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder={t('mcp.serverDescriptionPlaceholder')} + /> +
+ + {/* Command-based fields */} + {formData.type === 'command' && ( + <> +
+ + { + setFormData(prev => ({ ...prev, command: e.target.value })); + setError(null); + }} + placeholder="npx" + /> +
+
+ + setArgsInput(e.target.value)} + placeholder="-y @myorg/my-mcp-server" + /> +

{t('mcp.argsHint')}

+
+ + )} + + {/* HTTP-based fields */} + {formData.type === 'http' && ( + <> +
+ + { + setFormData(prev => ({ ...prev, url: e.target.value })); + setError(null); + }} + placeholder="https://mcp.example.com/mcp" + /> +
+ + {/* URL-based hint for known providers */} + {urlHint && ( + + )} + + {/* Authentication Token (simplified) */} +
+ + setBearerToken(e.target.value)} + placeholder={t('mcp.authTokenPlaceholder')} + type="password" + /> +

{t('mcp.authTokenHint')}

+
+ + {/* Advanced Headers (collapsible) */} +
+ + + {showAdvancedHeaders && ( +
+
+ setHeaderKey(e.target.value)} + placeholder={t('mcp.headerName')} + className="flex-1" + /> + setHeaderValue(e.target.value)} + placeholder={t('mcp.headerValue')} + className="flex-1" + type="password" + /> + +
+ {/* Show non-Authorization headers */} + {Object.entries(formData.headers || {}).filter(([key]) => key.toLowerCase() !== 'authorization').length > 0 && ( +
+ {Object.entries(formData.headers || {}) + .filter(([key]) => key.toLowerCase() !== 'authorization') + .map(([key, value]) => ( +
+ + {key}:{' '} + + {value.length > 20 ? `${value.substring(0, 20)}...` : value} + + + +
+ ))} +
+ )} +
+ )} +
+ + )} +
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 1d29ad06..7b0ad639 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -116,7 +116,7 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArc
(null); const [pendingProject, setPendingProject] = useState(null); const [isInitializing, setIsInitializing] = useState(false); + const [envConfig, setEnvConfig] = useState(null); const selectedProject = projects.find((p) => p.id === selectedProjectId); + // Load env config when project changes to check GitHub/GitLab enabled state + useEffect(() => { + const loadEnvConfig = async () => { + if (selectedProject?.autoBuildPath) { + try { + const result = await window.electronAPI.getProjectEnv(selectedProject.id); + if (result.success && result.data) { + setEnvConfig(result.data); + } else { + setEnvConfig(null); + } + } catch { + setEnvConfig(null); + } + } else { + setEnvConfig(null); + } + }; + loadEnvConfig(); + }, [selectedProject?.id, selectedProject?.autoBuildPath]); + + // Compute visible nav items based on GitHub/GitLab enabled state + const visibleNavItems = useMemo(() => { + const items = [...baseNavItems]; + + if (envConfig?.githubEnabled) { + items.push(...githubNavItems); + } + + if (envConfig?.gitlabEnabled) { + items.push(...gitlabNavItems); + } + + return items; + }, [envConfig?.githubEnabled, envConfig?.gitlabEnabled]); + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -128,9 +172,8 @@ export function Sidebar({ const key = e.key.toUpperCase(); - // Find matching nav item - const allNavItems = [...projectNavItems, ...toolsNavItems]; - const matchedItem = allNavItems.find((item) => item.shortcut === key); + // Find matching nav item from visible items only + const matchedItem = visibleNavItems.find((item) => item.shortcut === key); if (matchedItem) { e.preventDefault(); @@ -140,7 +183,7 @@ export function Sidebar({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedProjectId, onViewChange]); + }, [selectedProjectId, onViewChange, visibleNavItems]); // Check git status when project changes useEffect(() => { @@ -268,22 +311,12 @@ export function Sidebar({
{/* Project Section */} -
+

{t('sections.project')}

-
- - {/* Tools Section */} -
-

- {t('sections.tools')} -

-
@@ -296,6 +329,9 @@ export function Sidebar({ {/* Bottom section with Settings, Help, and New Task */}
+ {/* Claude Code Status Badge */} + + {/* Settings and Help row */}
diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 6256d8ba..264bb18d 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -192,7 +192,7 @@ export function TaskCard({ task, onClick }: TaskCardProps) { > {task.title} -
+
{/* Stuck indicator - highest priority */} {isStuck && ( { + if (selectedProjectId && selectedPRNumber) { + return await window.electronAPI.github.getPRLogs(selectedProjectId, selectedPRNumber); + } + return null; + }, [selectedProjectId, selectedPRNumber]); + // Not connected state if (!isConnected) { return ; @@ -233,6 +240,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { onPostComment={handlePostComment} onMergePR={handleMergePR} onAssignPR={handleAssignPR} + onGetLogs={handleGetLogs} /> ) : ( diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx index 25f5f263..0f249958 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx @@ -12,6 +12,7 @@ import { AlertTriangle, CheckCheck, MessageSquare, + FileText, } from 'lucide-react'; import { Badge } from '../../ui/badge'; import { Button } from '../../ui/button'; @@ -25,9 +26,10 @@ import { CollapsibleCard } from './CollapsibleCard'; import { ReviewStatusTree } from './ReviewStatusTree'; import { PRHeader } from './PRHeader'; import { ReviewFindings } from './ReviewFindings'; +import { PRLogs } from './PRLogs'; import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs'; -import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api'; +import type { NewCommitsCheck, PRLogs as PRLogsType } from '../../../../preload/api/modules/github-api'; interface PRDetailProps { pr: PRData; @@ -44,6 +46,7 @@ interface PRDetailProps { onPostComment: (body: string) => void; onMergePR: (mergeMethod?: 'merge' | 'squash' | 'rebase') => void; onAssignPR: (username: string) => void; + onGetLogs: () => Promise; } function getStatusColor(status: PRReviewResult['overallStatus']): string { @@ -72,6 +75,7 @@ export function PRDetail({ onPostComment, onMergePR, onAssignPR: _onAssignPR, + onGetLogs, }: PRDetailProps) { const { t, i18n } = useTranslation('common'); // Selection state for findings @@ -87,6 +91,11 @@ export function PRDetail({ const checkNewCommitsAbortRef = useRef(null); // Ref to track checking state without causing callback recreation const isCheckingNewCommitsRef = useRef(false); + // Logs state + const [logsExpanded, setLogsExpanded] = useState(false); + const [prLogs, setPrLogs] = useState(null); + const [isLoadingLogs, setIsLoadingLogs] = useState(false); + const logsLoadedRef = useRef(false); // Sync with store's newCommitsCheck when it changes (e.g., when switching PRs) useEffect(() => { @@ -104,18 +113,19 @@ export function PRDetail({ } }, [reviewResult?.postedFindingIds, pr.number]); - // Auto-select critical and high findings when review completes (excluding already posted) + // Auto-select ALL findings when review completes (excluding already posted) + // All findings should reach the contributor - even LOW suggestions are valuable feedback useEffect(() => { if (reviewResult?.success && reviewResult.findings.length > 0) { - const importantFindings = reviewResult.findings - .filter(f => (f.severity === 'critical' || f.severity === 'high') && !postedFindingIds.has(f.id)) + const allFindings = reviewResult.findings + .filter(f => !postedFindingIds.has(f.id)) .map(f => f.id); - setSelectedFindingIds(new Set(importantFindings)); + setSelectedFindingIds(new Set(allFindings)); } }, [reviewResult, postedFindingIds]); - // Check for new commits only when findings have been posted to GitHub - // Follow-up review only makes sense after initial findings are shared with the contributor + // Check for new commits after any review has been completed + // This allows detecting new work pushed after ANY review (initial or follow-up) const hasPostedFindings = postedFindingIds.size > 0 || reviewResult?.hasPostedFindings; const checkForNewCommits = useCallback(async () => { @@ -130,8 +140,10 @@ export function PRDetail({ } checkNewCommitsAbortRef.current = new AbortController(); - // Only check for new commits if we have a review AND findings have been posted - if (reviewResult?.success && reviewResult.reviewedCommitSha && hasPostedFindings) { + // Check for new commits if we have ANY successful review with a commit SHA + // This includes follow-up reviews that resolved all issues (no new findings) + // New commits = new code that needs to be reviewed, regardless of posting status + if (reviewResult?.success && reviewResult.reviewedCommitSha) { isCheckingNewCommitsRef.current = true; try { const result = await onCheckNewCommits(); @@ -144,11 +156,8 @@ export function PRDetail({ isCheckingNewCommitsRef.current = false; } } - } else { - // Clear any existing new commits check if we haven't posted yet - setNewCommitsCheck(null); } - }, [reviewResult, onCheckNewCommits, hasPostedFindings]); + }, [reviewResult, onCheckNewCommits]); useEffect(() => { checkForNewCommits(); @@ -168,6 +177,70 @@ export function PRDetail({ } }, [postSuccess]); + // Auto-expand logs section when review starts + useEffect(() => { + if (isReviewing) { + setLogsExpanded(true); + } + }, [isReviewing]); + + // Load logs when logs section is expanded or when reviewing (for live logs) + useEffect(() => { + if (logsExpanded && !logsLoadedRef.current && !isLoadingLogs) { + logsLoadedRef.current = true; + setIsLoadingLogs(true); + onGetLogs() + .then(logs => setPrLogs(logs)) + .catch(() => setPrLogs(null)) + .finally(() => setIsLoadingLogs(false)); + } + }, [logsExpanded, onGetLogs, isLoadingLogs]); + + // Track previous reviewing state to detect transitions + const wasReviewingRef = useRef(false); + + // Refresh logs periodically while reviewing (even faster during active review) + useEffect(() => { + const wasReviewing = wasReviewingRef.current; + wasReviewingRef.current = isReviewing; + + // Do one final refresh when review just completed to get final phase status + if (wasReviewing && !isReviewing) { + onGetLogs() + .then(logs => setPrLogs(logs)) + .catch(err => console.error('Failed to fetch final logs:', err)); + return; + } + + // Clear old logs when a new review starts to avoid showing stale status + if (!wasReviewing && isReviewing) { + setPrLogs(null); + } + + if (!isReviewing) return; + + const refreshLogs = async () => { + try { + const logs = await onGetLogs(); + setPrLogs(logs); + } catch { + // Ignore errors during refresh + } + }; + + // Refresh immediately, then every 1.5 seconds while reviewing for smoother streaming + refreshLogs(); + const interval = setInterval(refreshLogs, 1500); + return () => clearInterval(interval); + }, [isReviewing, onGetLogs]); + + // Reset logs state when PR changes + useEffect(() => { + logsLoadedRef.current = false; + setPrLogs(null); + setLogsExpanded(false); + }, [pr.number]); + // Count selected findings by type for the button label const selectedCount = selectedFindingIds.size; @@ -222,6 +295,8 @@ export function PRDetail({ const hasUnpostedBlockers = unpostedFindings.some(f => f.severity === 'critical' || f.severity === 'high'); const hasNewCommits = newCommitsCheck?.hasNewCommits ?? false; const newCommitCount = newCommitsCheck?.newCommitCount ?? 0; + // Only consider commits that happened AFTER findings were posted for "Ready for Follow-up" + const hasCommitsAfterPosting = newCommitsCheck?.hasCommitsAfterPosting ?? false; // Follow-up review specific statuses if (reviewResult.isFollowupReview) { @@ -234,8 +309,8 @@ export function PRDetail({ f => (f.severity === 'critical' || f.severity === 'high') ); - // Check if ready for another follow-up (new commits after this follow-up) - if (hasNewCommits) { + // Check if ready for another follow-up (new commits AFTER this follow-up was posted) + if (hasNewCommits && hasCommitsAfterPosting) { return { status: 'ready_for_followup', label: t('prReview.readyForFollowup'), @@ -280,8 +355,8 @@ export function PRDetail({ // Initial review statuses (non-follow-up) - // Priority 1: Ready for follow-up review (posted findings + new commits) - if (hasPosted && hasNewCommits) { + // Priority 1: Ready for follow-up review (posted findings + new commits AFTER posting) + if (hasPosted && hasNewCommits && hasCommitsAfterPosting) { return { status: 'ready_for_followup', label: t('prReview.readyForFollowup'), @@ -622,6 +697,35 @@ ${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking iss )} + {/* Review Logs - show during review or after completion */} + {(reviewResult || isReviewing) && ( + } + badge={ + isReviewing ? ( + + + {t('prReview.aiReviewInProgress')} + + ) : prLogs ? ( + + {prLogs.is_followup ? t('prReview.followup') : t('prReview.initial')} + + ) : null + } + open={logsExpanded} + onOpenChange={setLogsExpanded} + > + + + )} + {/* Description */} diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx index 69c887ff..c4f67ba4 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -22,6 +22,8 @@ interface PRStatusFlowProps { hasPosted: boolean; hasBlockingFindings: boolean; hasNewCommits: boolean; + /** Whether commits happened AFTER findings were posted - for "Ready for Follow-up" status */ + hasCommitsAfterPosting: boolean; t: (key: string) => string; } @@ -34,6 +36,7 @@ function PRStatusFlow({ hasPosted, hasBlockingFindings, hasNewCommits, + hasCommitsAfterPosting, t, }: PRStatusFlowProps) { // Determine flow state - prioritize more advanced states first @@ -51,7 +54,10 @@ function PRStatusFlow({ // Determine final status color for posted state let finalStatus: FinalStatus = 'success'; - if (hasNewCommits) { + // Only show "Ready for Follow-up" if there are commits AFTER findings were posted + // This prevents showing follow-up status for commits that happened during/before the review + // hasNewCommits tells us the commits are different, hasCommitsAfterPosting tells us if they're newer + if (hasNewCommits && hasCommitsAfterPosting) { finalStatus = 'followup'; } else if (hasBlockingFindings) { finalStatus = 'warning'; @@ -256,10 +262,16 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewState // Follow-up review with no new findings to post is effectively "posted" (Boolean(reviewState?.result?.isFollowupReview) && reviewState?.result?.findings?.length === 0) } - hasBlockingFindings={Boolean(reviewState?.result?.findings?.some( - f => f.severity === 'critical' || f.severity === 'high' - ))} + hasBlockingFindings={ + // Use overallStatus from review result as source of truth + reviewState?.result?.overallStatus === 'request_changes' || + // Fallback to checking findings severity + Boolean(reviewState?.result?.findings?.some( + f => f.severity === 'critical' || f.severity === 'high' + )) + } hasNewCommits={Boolean(reviewState?.newCommitsCheck?.hasNewCommits)} + hasCommitsAfterPosting={reviewState?.newCommitsCheck?.hasCommitsAfterPosting ?? false} t={t} />
diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRLogs.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRLogs.tsx new file mode 100644 index 00000000..9c3522ae --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/PRLogs.tsx @@ -0,0 +1,357 @@ +import { useState } from 'react'; +import { + Terminal, + Loader2, + FolderOpen, + BrainCircuit, + FileCheck, + CheckCircle2, + XCircle, + ChevronDown, + ChevronRight, + Info, + Clock +} from 'lucide-react'; +import { Badge } from '../../ui/badge'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible'; +import { cn } from '../../../lib/utils'; +import type { + PRLogs, + PRLogPhase, + PRPhaseLog, + PRLogEntry +} from '../../../../preload/api/modules/github-api'; + +interface PRLogsProps { + prNumber: number; + logs: PRLogs | null; + isLoading: boolean; + isStreaming?: boolean; +} + +const PHASE_LABELS: Record = { + context: 'Context Gathering', + analysis: 'AI Analysis', + synthesis: 'Synthesis' +}; + +const PHASE_ICONS: Record = { + context: FolderOpen, + analysis: BrainCircuit, + synthesis: FileCheck +}; + +const PHASE_COLORS: Record = { + context: 'text-blue-500 bg-blue-500/10 border-blue-500/30', + analysis: 'text-purple-500 bg-purple-500/10 border-purple-500/30', + synthesis: 'text-green-500 bg-green-500/10 border-green-500/30' +}; + +// Source colors for different log sources +const SOURCE_COLORS: Record = { + 'Context': 'bg-blue-500/20 text-blue-400', + 'AI': 'bg-purple-500/20 text-purple-400', + 'Orchestrator': 'bg-orange-500/20 text-orange-400', + 'ParallelOrchestrator': 'bg-orange-500/20 text-orange-400', + 'Followup': 'bg-cyan-500/20 text-cyan-400', + 'ParallelFollowup': 'bg-cyan-500/20 text-cyan-400', + 'BotDetector': 'bg-amber-500/20 text-amber-400', + 'Progress': 'bg-green-500/20 text-green-400', + 'PR Review Engine': 'bg-indigo-500/20 text-indigo-400', + 'Summary': 'bg-emerald-500/20 text-emerald-400', + // Specialist agents (from parallel orchestrator) + 'Agent:logic-reviewer': 'bg-blue-600/20 text-blue-400', + 'Agent:quality-reviewer': 'bg-indigo-600/20 text-indigo-400', + 'Agent:security-reviewer': 'bg-red-600/20 text-red-400', + 'Agent:ai-triage-reviewer': 'bg-slate-500/20 text-slate-400', + // Specialist agents (from parallel followup reviewer) + 'Agent:resolution-verifier': 'bg-teal-600/20 text-teal-400', + 'Agent:new-code-reviewer': 'bg-cyan-600/20 text-cyan-400', + 'Agent:comment-analyzer': 'bg-gray-500/20 text-gray-400', + 'default': 'bg-muted text-muted-foreground' +}; + +export function PRLogs({ prNumber, logs, isLoading, isStreaming = false }: PRLogsProps) { + const [expandedPhases, setExpandedPhases] = useState>(new Set(['analysis'])); + + const togglePhase = (phase: PRLogPhase) => { + setExpandedPhases(prev => { + const next = new Set(prev); + if (next.has(phase)) { + next.delete(phase); + } else { + next.add(phase); + } + return next; + }); + }; + + return ( +
+
+ {isLoading && !logs ? ( +
+ +
+ ) : logs ? ( + <> + {/* Logs header */} +
+
+ PR #{prNumber} + {logs.is_followup && Follow-up} + {isStreaming && ( + + + Live + + )} +
+
+ + {new Date(logs.updated_at).toLocaleString()} +
+
+ + {/* Phase-based collapsible logs */} + {(['context', 'analysis', 'synthesis'] as PRLogPhase[]).map((phase) => ( + togglePhase(phase)} + isStreaming={isStreaming} + /> + ))} + + ) : isStreaming ? ( +
+ +

Waiting for logs...

+

Review is starting

+
+ ) : ( +
+ +

No logs available

+

Run a review to generate logs

+
+ )} +
+
+ ); +} + +// Phase Log Section Component +interface PhaseLogSectionProps { + phase: PRLogPhase; + phaseLog: PRPhaseLog | null; + isExpanded: boolean; + onToggle: () => void; + isStreaming?: boolean; +} + +function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isStreaming = false }: PhaseLogSectionProps) { + const Icon = PHASE_ICONS[phase]; + const status = phaseLog?.status || 'pending'; + const hasEntries = (phaseLog?.entries.length || 0) > 0; + + const getStatusBadge = () => { + // Show streaming indicator for active phase during streaming + if (status === 'active' || (isStreaming && status === 'pending')) { + return ( + + + {isStreaming ? 'Streaming' : 'Running'} + + ); + } + switch (status) { + case 'completed': + return ( + + + Complete + + ); + case 'failed': + return ( + + + Failed + + ); + default: + return ( + + Pending + + ); + } + }; + + return ( + + + + + +
+ {!hasEntries ? ( +

No logs yet

+ ) : ( + phaseLog?.entries.map((entry, idx) => ( + + )) + )} +
+
+
+ ); +} + +// Log Entry Component +interface LogEntryProps { + entry: PRLogEntry; +} + +function LogEntry({ entry }: LogEntryProps) { + const [isExpanded, setIsExpanded] = useState(false); + const hasDetail = Boolean(entry.detail); + + const formatTime = (timestamp: string) => { + try { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } + }; + + const getSourceColor = (source: string | undefined) => { + if (!source) return SOURCE_COLORS.default; + return SOURCE_COLORS[source] || SOURCE_COLORS.default; + }; + + if (entry.type === 'error') { + return ( +
+
+ + {entry.content} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+              {entry.detail}
+            
+
+ )} +
+ ); + } + + if (entry.type === 'success') { + return ( +
+ + {entry.content} +
+ ); + } + + if (entry.type === 'info') { + return ( +
+ + {entry.content} +
+ ); + } + + // Default text entry with source badge + return ( +
+
+ + {formatTime(entry.timestamp)} + + {entry.source && ( + + {entry.source} + + )} + {entry.content} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+            {entry.detail}
+          
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx b/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx index 0ac08253..cd5e3356 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx @@ -15,6 +15,7 @@ import { AlertTriangle, CheckSquare, Square, + Send, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../ui/button'; @@ -47,7 +48,16 @@ export function ReviewFindings({ new Set(['critical', 'high']) // Critical and High expanded by default ); - // Group findings by severity + // Filter out posted findings - only show unposted findings for selection + const unpostedFindings = useMemo(() => + findings.filter(f => !postedIds.has(f.id)), + [findings, postedIds] + ); + + // Check if all findings are posted + const allFindingsPosted = findings.length > 0 && unpostedFindings.length === 0; + + // Group unposted findings by severity (only show findings that haven't been posted) const groupedFindings = useMemo(() => { const groups: Record = { critical: [], @@ -56,7 +66,7 @@ export function ReviewFindings({ low: [], }; - for (const finding of findings) { + for (const finding of unpostedFindings) { const severity = finding.severity as SeverityGroup; if (groups[severity]) { groups[severity].push(finding); @@ -64,19 +74,20 @@ export function ReviewFindings({ } return groups; - }, [findings]); + }, [unpostedFindings]); - // Count by severity + // Count by severity (unposted findings only) const counts = useMemo(() => ({ critical: groupedFindings.critical.length, high: groupedFindings.high.length, medium: groupedFindings.medium.length, low: groupedFindings.low.length, - total: findings.length, + total: unpostedFindings.length, important: groupedFindings.critical.length + groupedFindings.high.length, - }), [groupedFindings, findings.length]); + posted: postedIds.size, + }), [groupedFindings, unpostedFindings.length, postedIds.size]); - // Selection hooks + // Selection hooks - use unposted findings only const { toggleFinding, selectAll, @@ -84,7 +95,7 @@ export function ReviewFindings({ selectImportant, toggleSeverityGroup, } = useFindingSelection({ - findings, + findings: unpostedFindings, selectedIds, onSelectionChange, groupedFindings, @@ -103,11 +114,26 @@ export function ReviewFindings({ }); }; + // When all findings have been posted, show a success message instead of the selection UI + if (allFindingsPosted) { + return ( +
+
+ +

{t('prReview.allFindingsPosted')}

+

+ {t('prReview.findingsPostedCount', { count: counts.posted })} +

+
+
+ ); + } + return (
- {/* Summary Stats Bar */} + {/* Summary Stats Bar - show unposted findings only */} @@ -144,7 +170,7 @@ export function ReviewFindings({
- {/* Grouped Findings */} + {/* Grouped Findings (unposted only) */}
{SEVERITY_ORDER.map((severity) => { const group = groupedFindings[severity]; @@ -183,7 +209,7 @@ export function ReviewFindings({ key={finding.id} finding={finding} selected={selectedIds.has(finding.id)} - posted={postedIds.has(finding.id)} + posted={false} onToggle={() => toggleFinding(finding.id)} /> ))} @@ -194,7 +220,7 @@ export function ReviewFindings({ })}
- {/* Empty State */} + {/* Empty State - no findings at all */} {findings.length === 0 && (
diff --git a/apps/frontend/src/renderer/components/github-prs/components/ReviewStatusTree.tsx b/apps/frontend/src/renderer/components/github-prs/components/ReviewStatusTree.tsx index 2293d453..f9d81ac8 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/ReviewStatusTree.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/ReviewStatusTree.tsx @@ -164,8 +164,9 @@ export function ReviewStatusTree({ }); } - // Step 4: Follow-up (only show when not currently reviewing) - if (!isReviewing && newCommitsCheck?.hasNewCommits) { + // Step 4: Follow-up (only show when not currently reviewing AND commits happened after posting) + // This prevents showing follow-up prompts for commits that were made during/before the review + if (!isReviewing && newCommitsCheck?.hasNewCommits && newCommitsCheck?.hasCommitsAfterPosting) { steps.push({ id: 'new_commits', label: t('prReview.newCommits', { count: newCommitsCheck.newCommitCount }), diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 54867550..24c7d468 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -104,18 +104,47 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { setPrs(result); // Preload review results for all PRs - result.forEach(pr => { + const preloadPromises = result.map(async (pr) => { const existingState = getPRReviewState(projectId, pr.number); // Only fetch from disk if we don't have a result in the store if (!existingState?.result) { - window.electronAPI.github.getPRReview(projectId, pr.number).then(reviewResult => { - if (reviewResult) { - // Update store with the loaded result - usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult); - } - }); + const reviewResult = await window.electronAPI.github.getPRReview(projectId, pr.number); + if (reviewResult) { + // Update store with the loaded result + usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult); + return { prNumber: pr.number, reviewResult }; + } + } else { + return { prNumber: pr.number, reviewResult: existingState.result }; } + return null; }); + + // Wait for all preloads to complete, then check for new commits + const preloadResults = await Promise.all(preloadPromises); + + // Check for new commits on PRs that have been reviewed + // (either has reviewedCommitSha or the snake_case variant from older reviews) + const prsWithReviews = preloadResults.filter( + (r): r is { prNumber: number; reviewResult: PRReviewResult } => + r !== null && + (!!r.reviewResult?.reviewedCommitSha || !!(r.reviewResult as any)?.reviewed_commit_sha) + ); + + if (prsWithReviews.length > 0) { + // Check new commits in parallel for all reviewed PRs + await Promise.all( + prsWithReviews.map(async ({ prNumber }) => { + try { + const newCommitsResult = await window.electronAPI.github.checkNewCommits(projectId, prNumber); + usePRReviewStore.getState().setNewCommitsCheck(projectId, prNumber, newCommitsResult); + } catch (err) { + // Silently fail for individual PR checks - don't block the list + console.warn(`Failed to check new commits for PR #${prNumber}:`, err); + } + }) + ); + } } } } else { diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts index c362cc97..928871f3 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts @@ -51,13 +51,17 @@ function getPRComputedStatus( const result = reviewInfo.result; const hasPosted = Boolean(result.reviewId) || Boolean(result.hasPostedFindings); - const hasBlockingFindings = result.findings?.some( - f => f.severity === 'critical' || f.severity === 'high' - ); + // Use overallStatus from review result as source of truth, fallback to severity check + const hasBlockingFindings = + result.overallStatus === 'request_changes' || + result.findings?.some(f => f.severity === 'critical' || f.severity === 'high'); const hasNewCommits = reviewInfo.newCommitsCheck?.hasNewCommits; + // Only count commits that happened AFTER findings were posted for follow-up status + const hasCommitsAfterPosting = reviewInfo.newCommitsCheck?.hasCommitsAfterPosting; // Check for ready for follow-up first (highest priority after posting) - if (hasPosted && hasNewCommits) { + // Must have new commits that happened AFTER findings were posted + if (hasPosted && hasNewCommits && hasCommitsAfterPosting) { return 'ready_for_followup'; } diff --git a/apps/frontend/src/renderer/components/gitlab-merge-requests/components/SeverityGroupHeader.tsx b/apps/frontend/src/renderer/components/gitlab-merge-requests/components/SeverityGroupHeader.tsx index 3435ce06..82c93c3c 100644 --- a/apps/frontend/src/renderer/components/gitlab-merge-requests/components/SeverityGroupHeader.tsx +++ b/apps/frontend/src/renderer/components/gitlab-merge-requests/components/SeverityGroupHeader.tsx @@ -2,6 +2,7 @@ * SeverityGroupHeader - Collapsible header for a severity group with selection checkbox */ +import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronRight, CheckSquare, Square, MinusSquare } from 'lucide-react'; import { Badge } from '../../ui/badge'; import { cn } from '../../../lib/utils'; @@ -25,6 +26,7 @@ export function SeverityGroupHeader({ onToggle, onSelectAll, }: SeverityGroupHeaderProps) { + const { t } = useTranslation(['gitlab', 'common']); const config = SEVERITY_CONFIG[severity]; const Icon = config.icon; const isFullySelected = selectedCount === count && count > 0; @@ -53,13 +55,13 @@ export function SeverityGroupHeader({ - {config.label} + {t(config.labelKey)} {count} - {config.description} + {t(config.descriptionKey)}
{expanded ? ( diff --git a/apps/frontend/src/renderer/components/gitlab-merge-requests/constants/severity-config.ts b/apps/frontend/src/renderer/components/gitlab-merge-requests/constants/severity-config.ts index c5221447..989d7539 100644 --- a/apps/frontend/src/renderer/components/gitlab-merge-requests/constants/severity-config.ts +++ b/apps/frontend/src/renderer/components/gitlab-merge-requests/constants/severity-config.ts @@ -19,39 +19,39 @@ export type SeverityGroup = 'critical' | 'high' | 'medium' | 'low'; export const SEVERITY_ORDER: SeverityGroup[] = ['critical', 'high', 'medium', 'low']; export const SEVERITY_CONFIG: Record = { critical: { - label: 'Critical', + labelKey: 'mrReview.severity.critical', color: 'text-red-500', bgColor: 'bg-red-500/10 border-red-500/30', icon: XCircle, - description: 'Must fix before merge', + descriptionKey: 'mrReview.severity.criticalDesc', }, high: { - label: 'High', + labelKey: 'mrReview.severity.high', color: 'text-orange-500', bgColor: 'bg-orange-500/10 border-orange-500/30', icon: AlertTriangle, - description: 'Should fix before merge', + descriptionKey: 'mrReview.severity.highDesc', }, medium: { - label: 'Medium', + labelKey: 'mrReview.severity.medium', color: 'text-yellow-500', bgColor: 'bg-yellow-500/10 border-yellow-500/30', icon: AlertCircle, - description: 'Consider fixing', + descriptionKey: 'mrReview.severity.mediumDesc', }, low: { - label: 'Low', + labelKey: 'mrReview.severity.low', color: 'text-blue-500', bgColor: 'bg-blue-500/10 border-blue-500/30', icon: CheckCircle, - description: 'Nice to have', + descriptionKey: 'mrReview.severity.lowDesc', }, }; diff --git a/apps/frontend/src/renderer/components/ideation/IdeaDetailPanel.tsx b/apps/frontend/src/renderer/components/ideation/IdeaDetailPanel.tsx index 03292760..35b426b2 100644 --- a/apps/frontend/src/renderer/components/ideation/IdeaDetailPanel.tsx +++ b/apps/frontend/src/renderer/components/ideation/IdeaDetailPanel.tsx @@ -96,7 +96,10 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis +
+ + + + {/* Error message */} + {error && status !== 'loading' && ( + + +

{error}

+
+
+ )} + + {/* Install success message */} + {installSuccess && ( + + +

+ {t('claudeCode.install.success', 'Installation command sent to terminal. Please complete the installation there.')} +

+

+ {t('claudeCode.install.instructions', 'The installer will open in your terminal. Follow the prompts to complete installation.')} +

+
+
+ )} + + {/* Install/Update button */} + {(status === 'not-found' || status === 'outdated') && !installSuccess && ( +
+ +
+ )} + + {/* Documentation link */} +
+ +
+
+ + {/* Navigation buttons */} +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx b/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx index cf11e0cf..1ab18917 100644 --- a/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx +++ b/apps/frontend/src/renderer/components/onboarding/OnboardingWizard.tsx @@ -13,6 +13,7 @@ import { ScrollArea } from '../ui/scroll-area'; import { WizardProgress, WizardStep } from './WizardProgress'; import { WelcomeStep } from './WelcomeStep'; import { OAuthStep } from './OAuthStep'; +import { ClaudeCodeStep } from './ClaudeCodeStep'; import { DevToolsStep } from './DevToolsStep'; import { MemoryStep } from './MemoryStep'; import { CompletionStep } from './CompletionStep'; @@ -26,12 +27,13 @@ interface OnboardingWizardProps { } // Wizard step identifiers -type WizardStepId = 'welcome' | 'oauth' | 'devtools' | 'memory' | 'completion'; +type WizardStepId = 'welcome' | 'oauth' | 'claude-code' | 'devtools' | 'memory' | 'completion'; // Step configuration with translation keys const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [ { id: 'welcome', labelKey: 'steps.welcome' }, { id: 'oauth', labelKey: 'steps.auth' }, + { id: 'claude-code', labelKey: 'steps.claudeCode' }, { id: 'devtools', labelKey: 'steps.devtools' }, { id: 'memory', labelKey: 'steps.memory' }, { id: 'completion', labelKey: 'steps.done' } @@ -157,6 +159,14 @@ export function OnboardingWizard({ onSkip={skipWizard} /> ); + case 'claude-code': + return ( + + ); case 'devtools': return ( (null); + // glab CLI detection state + const [glabInstalled, setGlabInstalled] = useState(null); + const [glabVersion, setGlabVersion] = useState(null); + const [isCheckingGlab, setIsCheckingGlab] = useState(false); + const [isInstallingGlab, setIsInstallingGlab] = useState(false); + const [glabInstallSuccess, setGlabInstallSuccess] = useState(false); + debugLog('Render - authMode:', authMode); debugLog('Render - projectPath:', projectPath); debugLog('Render - envConfig:', envConfig ? { gitlabEnabled: envConfig.gitlabEnabled, hasToken: !!envConfig.gitlabToken, defaultBranch: envConfig.defaultBranch } : null); @@ -74,6 +81,29 @@ export function GitLabIntegration({ } }, [authMode]); + // Check glab CLI on mount + useEffect(() => { + const checkGlab = async () => { + setIsCheckingGlab(true); + try { + const result = await window.electronAPI.checkGitLabCli(); + debugLog('checkGitLabCli result:', result); + if (result.success && result.data) { + setGlabInstalled(result.data.installed); + setGlabVersion(result.data.version || null); + } else { + setGlabInstalled(false); + } + } catch (error) { + debugLog('Error checking glab CLI:', error); + setGlabInstalled(false); + } finally { + setIsCheckingGlab(false); + } + }; + checkGlab(); + }, []); + // Fetch branches when GitLab is enabled and project path is available useEffect(() => { debugLog(`useEffect[branches] - gitlabEnabled: ${envConfig?.gitlabEnabled}, projectPath: ${projectPath}`); @@ -211,6 +241,48 @@ export function GitLabIntegration({ updateEnvConfig({ gitlabProject: projectPath }); }; + const handleInstallGlab = async () => { + setIsInstallingGlab(true); + setGlabInstallSuccess(false); + try { + const result = await window.electronAPI.installGitLabCli(); + debugLog('installGitLabCli result:', result); + if (result.success) { + setGlabInstallSuccess(true); + // Re-check after 5 seconds to give user time to complete installation + setTimeout(async () => { + await handleRefreshGlab(); + setIsInstallingGlab(false); + }, 5000); + } else { + setIsInstallingGlab(false); + } + } catch (error) { + debugLog('Error installing glab:', error); + setIsInstallingGlab(false); + } + }; + + const handleRefreshGlab = async () => { + setIsCheckingGlab(true); + setGlabInstallSuccess(false); + try { + const result = await window.electronAPI.checkGitLabCli(); + debugLog('checkGitLabCli refresh result:', result); + if (result.success && result.data) { + setGlabInstalled(result.data.installed); + setGlabVersion(result.data.version || null); + } else { + setGlabInstalled(false); + } + } catch (error) { + debugLog('Error refreshing glab status:', error); + setGlabInstalled(false); + } finally { + setIsCheckingGlab(false); + } + }; + return (
@@ -305,6 +377,86 @@ export function GitLabIntegration({ {/* Manual Token Entry */} {authMode === 'manual' && ( <> + {/* glab CLI Required Card */} + {glabInstalled === false && ( +
+
+ +
+
+

{t('settings.cli.required')}

+

+ {t('settings.cli.notInstalled')} +

+
+ {glabInstallSuccess ? ( +
+
+
+ +

{t('settings.cli.installSuccess')}

+
+ +
+
+ ) : ( +
+ + + {t('settings.cli.learnMore')} + + +
+ )} +
+
+
+ )} + + {/* glab CLI Installed Success */} + {glabInstalled === true && glabVersion && ( +
+
+ +

+ {t('settings.cli.installed')} {glabVersion} +

+
+
+ )} +
@@ -312,9 +464,14 @@ export function GitLabIntegration({ variant="outline" size="sm" onClick={handleSwitchToOAuth} + disabled={glabInstalled === false || isCheckingGlab} className="gap-2" > - + {isCheckingGlab ? ( + + ) : ( + + )} {t('settings.useOAuth')}
diff --git a/apps/frontend/src/renderer/components/ui/popover.tsx b/apps/frontend/src/renderer/components/ui/popover.tsx new file mode 100644 index 00000000..fce85513 --- /dev/null +++ b/apps/frontend/src/renderer/components/ui/popover.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '../../lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index 59c0837e..917a84b2 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -156,6 +156,7 @@ const browserMockAPI: ElectronAPI = { deletePRReview: async () => true, checkNewCommits: async () => ({ hasNewCommits: false, newCommitCount: 0 }), runFollowupReview: () => {}, + getPRLogs: async () => null, onPRReviewProgress: () => () => {}, onPRReviewComplete: () => () => {}, onPRReviewError: () => () => {}, @@ -172,6 +173,47 @@ const browserMockAPI: ElectronAPI = { onAnalyzePreviewError: () => () => {} }, + // Claude Code Operations + checkClaudeCodeVersion: async () => ({ + success: true, + data: { + installed: '1.0.0', + latest: '1.0.0', + isOutdated: false, + path: '/usr/local/bin/claude', + detectionResult: { + found: true, + version: '1.0.0', + path: '/usr/local/bin/claude', + source: 'system-path' as const, + message: 'Claude Code CLI found' + } + } + }), + installClaudeCode: async () => ({ + success: true, + data: { command: 'npm install -g @anthropic-ai/claude-code' } + }), + + // MCP Server Health Check Operations + checkMcpHealth: async (server) => ({ + success: true, + data: { + serverId: server.id, + status: 'unknown' as const, + message: 'Health check not available in browser mode', + checkedAt: new Date().toISOString() + } + }), + testMcpConnection: async (server) => ({ + success: true, + data: { + serverId: server.id, + success: false, + message: 'Connection test not available in browser mode' + } + }), + // Debug Operations getDebugInfo: async () => ({ systemInfo: { diff --git a/apps/frontend/src/renderer/lib/mocks/integration-mock.ts b/apps/frontend/src/renderer/lib/mocks/integration-mock.ts index 8d5edf65..9596563f 100644 --- a/apps/frontend/src/renderer/lib/mocks/integration-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/integration-mock.ts @@ -312,6 +312,11 @@ export const integrationMock = { } }), + installGitLabCli: async () => ({ + success: false, + error: 'Not available in browser mock' + }), + checkGitLabAuth: async () => ({ success: true, data: { diff --git a/apps/frontend/src/renderer/stores/ideation-store.ts b/apps/frontend/src/renderer/stores/ideation-store.ts index 08dee815..f29232e1 100644 --- a/apps/frontend/src/renderer/stores/ideation-store.ts +++ b/apps/frontend/src/renderer/stores/ideation-store.ts @@ -26,6 +26,7 @@ export type IdeationTypeState = 'pending' | 'generating' | 'completed' | 'failed interface IdeationState { // Data + currentProjectId: string | null; session: IdeationSession | null; generationStatus: IdeationGenerationStatus; config: IdeationConfig; @@ -35,6 +36,7 @@ interface IdeationState { isGenerating: boolean; // Actions + setCurrentProjectId: (projectId: string | null) => void; setSession: (session: IdeationSession | null) => void; setIsGenerating: (isGenerating: boolean) => void; setGenerationStatus: (status: IdeationGenerationStatus) => void; @@ -86,6 +88,7 @@ const initialTypeStates: Record = { export const useIdeationStore = create((set) => ({ // Initial state + currentProjectId: null, session: null, generationStatus: initialGenerationStatus, config: initialConfig, @@ -95,6 +98,23 @@ export const useIdeationStore = create((set) => ({ isGenerating: false, // Actions + setCurrentProjectId: (projectId) => + set((state) => { + // If switching to a different project, clear the state + if (state.currentProjectId !== projectId) { + return { + currentProjectId: projectId, + session: null, + generationStatus: initialGenerationStatus, + logs: [], + typeStates: { ...initialTypeStates }, + selectedIds: new Set(), + isGenerating: false + }; + } + return { currentProjectId: projectId }; + }), + setSession: (session) => set({ session }), setIsGenerating: (isGenerating) => set({ isGenerating }), @@ -349,21 +369,28 @@ export const useIdeationStore = create((set) => ({ })); export async function loadIdeation(projectId: string): Promise { - if (useIdeationStore.getState().isGenerating) { + const store = useIdeationStore.getState(); + + // Set the current project ID (this clears state if switching projects) + store.setCurrentProjectId(projectId); + + if (store.isGenerating) { return; } const result = await window.electronAPI.getIdeation(projectId); // Check again after async operation to handle race condition - if (useIdeationStore.getState().isGenerating) { + const currentState = useIdeationStore.getState(); + if (currentState.isGenerating || currentState.currentProjectId !== projectId) { + // Project changed during async operation, ignore result return; } if (result.success && result.data) { - useIdeationStore.getState().setSession(result.data); + currentState.setSession(result.data); } else { - useIdeationStore.getState().setSession(null); + currentState.setSession(null); } } @@ -614,12 +641,26 @@ export function isUIUXIdea(idea: Idea): idea is Idea & { type: 'ui_ux_improvemen export function setupIdeationListeners(): () => void { const store = useIdeationStore.getState; + // Helper to check if event is for the current project + const isCurrentProject = (eventProjectId: string): boolean => { + const currentProjectId = store().currentProjectId; + return currentProjectId === eventProjectId; + }; + // Listen for progress updates - const unsubProgress = window.electronAPI.onIdeationProgress((_projectId, status) => { + const unsubProgress = window.electronAPI.onIdeationProgress((projectId, status) => { + // Only process events for the current project + if (!isCurrentProject(projectId)) { + if (window.DEBUG) { + console.log('[Ideation] Ignoring progress for different project:', projectId); + } + return; + } + // Debug logging if (window.DEBUG) { console.log('[Ideation] Progress update:', { - projectId: _projectId, + projectId, phase: status.phase, progress: status.progress, message: status.message @@ -629,17 +670,26 @@ export function setupIdeationListeners(): () => void { }); // Listen for log messages - const unsubLog = window.electronAPI.onIdeationLog((_projectId, log) => { + const unsubLog = window.electronAPI.onIdeationLog((projectId, log) => { + if (!isCurrentProject(projectId)) return; store().addLog(log); }); // Listen for individual ideation type completion (streaming) const unsubTypeComplete = window.electronAPI.onIdeationTypeComplete( - (_projectId, ideationType, ideas) => { + (projectId, ideationType, ideas) => { + // Only process events for the current project + if (!isCurrentProject(projectId)) { + if (window.DEBUG) { + console.log('[Ideation] Ignoring type complete for different project:', projectId); + } + return; + } + // Debug logging if (window.DEBUG) { console.log('[Ideation] Type completed:', { - projectId: _projectId, + projectId, ideationType, ideasCount: ideas.length, ideas: ideas.map(i => ({ id: i.id, title: i.title, type: i.type })) @@ -677,10 +727,13 @@ export function setupIdeationListeners(): () => void { // Listen for individual ideation type failure const unsubTypeFailed = window.electronAPI.onIdeationTypeFailed( - (_projectId, ideationType) => { + (projectId, ideationType) => { + // Only process events for the current project + if (!isCurrentProject(projectId)) return; + // Debug logging if (window.DEBUG) { - console.error('[Ideation] Type failed:', { projectId: _projectId, ideationType }); + console.error('[Ideation] Type failed:', { projectId, ideationType }); } store().setTypeState(ideationType as IdeationType, 'failed'); @@ -688,10 +741,18 @@ export function setupIdeationListeners(): () => void { } ); - const unsubComplete = window.electronAPI.onIdeationComplete((_projectId, session) => { + const unsubComplete = window.electronAPI.onIdeationComplete((projectId, session) => { + // Only process events for the current project + if (!isCurrentProject(projectId)) { + if (window.DEBUG) { + console.log('[Ideation] Ignoring complete for different project:', projectId); + } + return; + } + if (window.DEBUG) { console.log('[Ideation] Generation complete:', { - projectId: _projectId, + projectId, totalIdeas: session.ideas.length, ideaTypes: session.ideas.reduce((acc, idea) => { acc[idea.type] = (acc[idea.type] || 0) + 1; @@ -700,7 +761,7 @@ export function setupIdeationListeners(): () => void { }); } - clearGenerationTimeout(_projectId); + clearGenerationTimeout(projectId); store().setIsGenerating(false); store().setSession(session); @@ -713,12 +774,15 @@ export function setupIdeationListeners(): () => void { store().addLog('Ideation generation complete!'); }); - const unsubError = window.electronAPI.onIdeationError((_projectId, error) => { + const unsubError = window.electronAPI.onIdeationError((projectId, error) => { + // Only process events for the current project + if (!isCurrentProject(projectId)) return; + if (window.DEBUG) { - console.error('[Ideation] Error received:', { projectId: _projectId, error }); + console.error('[Ideation] Error received:', { projectId, error }); } - clearGenerationTimeout(_projectId); + clearGenerationTimeout(projectId); store().setIsGenerating(false); store().resetGeneratingTypes('failed'); @@ -731,12 +795,15 @@ export function setupIdeationListeners(): () => void { store().addLog(`Error: ${error}`); }); - const unsubStopped = window.electronAPI.onIdeationStopped((_projectId) => { + const unsubStopped = window.electronAPI.onIdeationStopped((projectId) => { + // Only process events for the current project + if (!isCurrentProject(projectId)) return; + if (window.DEBUG) { - console.log('[Ideation] Stopped:', { projectId: _projectId }); + console.log('[Ideation] Stopped:', { projectId }); } - clearGenerationTimeout(_projectId); + clearGenerationTimeout(projectId); store().setIsGenerating(false); store().resetGeneratingTypes('pending'); diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 774f02d9..b5f22d7a 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -231,6 +231,7 @@ export const IPC_CHANNELS = { // GitLab OAuth (glab CLI authentication) GITLAB_CHECK_CLI: 'gitlab:checkCli', + GITLAB_INSTALL_CLI: 'gitlab:installCli', GITLAB_CHECK_AUTH: 'gitlab:checkAuth', GITLAB_START_AUTH: 'gitlab:startAuth', GITLAB_GET_TOKEN: 'gitlab:getToken', @@ -350,6 +351,9 @@ export const IPC_CHANNELS = { GITHUB_PR_REVIEW_COMPLETE: 'github:pr:reviewComplete', GITHUB_PR_REVIEW_ERROR: 'github:pr:reviewError', + // GitHub PR Logs (for viewing AI review logs) + GITHUB_PR_GET_LOGS: 'github:pr:getLogs', + // GitHub Issue Triage operations GITHUB_TRIAGE_RUN: 'github:triage:run', GITHUB_TRIAGE_GET_RESULTS: 'github:triage:getResults', @@ -464,5 +468,13 @@ export const IPC_CHANNELS = { DEBUG_OPEN_LOGS_FOLDER: 'debug:openLogsFolder', DEBUG_COPY_DEBUG_INFO: 'debug:copyDebugInfo', DEBUG_GET_RECENT_ERRORS: 'debug:getRecentErrors', - DEBUG_LIST_LOG_FILES: 'debug:listLogFiles' + DEBUG_LIST_LOG_FILES: 'debug:listLogFiles', + + // Claude Code CLI operations + CLAUDE_CODE_CHECK_VERSION: 'claudeCode:checkVersion', + CLAUDE_CODE_INSTALL: 'claudeCode:install', + + // MCP Server health checks + MCP_CHECK_HEALTH: 'mcp:checkHealth', // Quick connectivity check + MCP_TEST_CONNECTION: 'mcp:testConnection' // Full MCP protocol test } as const; diff --git a/apps/frontend/src/shared/constants/models.ts b/apps/frontend/src/shared/constants/models.ts index 8501a72d..4c8a31e1 100644 --- a/apps/frontend/src/shared/constants/models.ts +++ b/apps/frontend/src/shared/constants/models.ts @@ -69,13 +69,14 @@ export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingCo // Feature Settings (Non-Pipeline Features) // ============================================ -// Default feature model configuration (for insights, ideation, roadmap, github) +// Default feature model configuration (for insights, ideation, roadmap, github, utility) export const DEFAULT_FEATURE_MODELS: FeatureModelConfig = { insights: 'sonnet', // Fast, responsive chat ideation: 'opus', // Creative ideation benefits from Opus roadmap: 'opus', // Strategic planning benefits from Opus githubIssues: 'opus', // Issue triage and analysis benefits from Opus - githubPrs: 'opus' // PR review benefits from thorough Opus analysis + githubPrs: 'opus', // PR review benefits from thorough Opus analysis + utility: 'haiku' // Fast utility operations (commit messages, merge resolution) }; // Default feature thinking configuration @@ -84,7 +85,8 @@ export const DEFAULT_FEATURE_THINKING: FeatureThinkingConfig = { ideation: 'high', // Deep thinking for creative ideas roadmap: 'high', // Strategic thinking for roadmap githubIssues: 'medium', // Moderate thinking for issue analysis - githubPrs: 'medium' // Moderate thinking for PR review + githubPrs: 'medium', // Moderate thinking for PR review + utility: 'low' // Fast thinking for utility operations }; // Feature labels for UI display @@ -93,7 +95,8 @@ export const FEATURE_LABELS: Record Promise>; + installGitLabCli: () => Promise>; checkGitLabAuth: (hostname?: string) => Promise>; startGitLabAuth: (hostname?: string) => Promise Promise>; + installClaudeCode: () => Promise>; + // Debug operations getDebugInfo: () => Promise<{ systemInfo: Record; @@ -734,6 +742,10 @@ export interface ElectronAPI { size: number; modified: string; }>>; + + // MCP Server health check operations + checkMcpHealth: (server: CustomMcpServer) => Promise>; + testMcpConnection: (server: CustomMcpServer) => Promise>; } declare global { diff --git a/apps/frontend/src/shared/types/project.ts b/apps/frontend/src/shared/types/project.ts index fd34224b..2e2e4b0c 100644 --- a/apps/frontend/src/shared/types/project.ts +++ b/apps/frontend/src/shared/types/project.ts @@ -318,6 +318,109 @@ export interface ProjectEnvConfig { // UI Settings enableFancyUi: boolean; + + // MCP Server Configuration (per-project overrides) + mcpServers?: { + /** Context7 documentation lookup - default: true */ + context7Enabled?: boolean; + /** Graphiti knowledge graph - default: true (if graphitiProviderConfig set) */ + graphitiEnabled?: boolean; + /** Linear MCP integration - default: follows linearEnabled */ + linearMcpEnabled?: boolean; + /** Electron desktop automation (QA only) - default: false */ + electronEnabled?: boolean; + /** Puppeteer browser automation (QA only) - default: false */ + puppeteerEnabled?: boolean; + }; + + // Per-agent MCP overrides (add/remove MCPs from specific agents) + agentMcpOverrides?: AgentMcpOverrides; + + // Custom MCP servers defined by the user + customMcpServers?: CustomMcpServer[]; +} + +/** + * Per-agent MCP override configuration. + * Stored in .auto-claude/.env as AGENT_MCP__ADD and AGENT_MCP__REMOVE + */ +export interface AgentMcpOverride { + /** MCP servers to add beyond the agent's defaults */ + add?: string[]; + /** MCP servers to remove from the agent's defaults */ + remove?: string[]; +} + +/** + * Map of agent type to their MCP overrides. + * Agent types match backend AGENT_CONFIGS keys (e.g., 'planner', 'coder', 'qa_reviewer') + */ +export interface AgentMcpOverrides { + [agentType: string]: AgentMcpOverride; +} + +/** + * Custom MCP server configuration. + * Users can add command-based (npx/npm) or HTTP-based servers. + */ +export interface CustomMcpServer { + /** Unique identifier (used for agent overrides: AGENT_MCP__ADD=myserver) */ + id: string; + /** Display name shown in UI */ + name: string; + /** Server type */ + type: 'command' | 'http'; + /** Command to execute (for type: 'command'). e.g., 'npx', 'npm', 'node' */ + command?: string; + /** Arguments for the command (for type: 'command'). e.g., ['-y', 'my-mcp-server'] */ + args?: string[]; + /** HTTP URL (for type: 'http'). e.g., 'https://mcp.example.com/mcp' */ + url?: string; + /** HTTP headers (for type: 'http'). e.g., { "Authorization": "Bearer ..." } */ + headers?: Record; + /** Optional description shown in UI */ + description?: string; +} + +/** + * MCP server health check status. + */ +export type McpHealthStatus = 'healthy' | 'unhealthy' | 'needs_auth' | 'unknown' | 'checking'; + +/** + * Result of a quick health check for a custom MCP server. + */ +export interface McpHealthCheckResult { + /** Server ID */ + serverId: string; + /** Health status */ + status: McpHealthStatus; + /** HTTP status code (for HTTP servers) */ + statusCode?: number; + /** Human-readable message */ + message?: string; + /** Response time in milliseconds */ + responseTime?: number; + /** Timestamp of the check */ + checkedAt: string; +} + +/** + * Result of a full MCP connection test. + */ +export interface McpTestConnectionResult { + /** Server ID */ + serverId: string; + /** Whether the connection was successful */ + success: boolean; + /** Human-readable message */ + message: string; + /** Detailed error if any */ + error?: string; + /** List of tools discovered (for successful connections) */ + tools?: string[]; + /** Response time in milliseconds */ + responseTime?: number; } // Auto Claude Initialization Types diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index 38b54c0c..76f668f1 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -187,6 +187,7 @@ export interface FeatureModelConfig { roadmap: ModelTypeShort; // Roadmap generation githubIssues: ModelTypeShort; // GitHub Issues automation githubPrs: ModelTypeShort; // GitHub PR review automation + utility: ModelTypeShort; // Utility agents (commit message, merge resolver) } // Feature-specific thinking level configuration @@ -196,6 +197,7 @@ export interface FeatureThinkingConfig { roadmap: ThinkingLevel; githubIssues: ThinkingLevel; githubPrs: ThinkingLevel; + utility: ThinkingLevel; } // Agent profile for preset model/thinking configurations diff --git a/tests/test_agent_configs.py b/tests/test_agent_configs.py index 30ea085b..00af9063 100644 --- a/tests/test_agent_configs.py +++ b/tests/test_agent_configs.py @@ -161,16 +161,31 @@ class TestGetRequiredMcpServers: os.environ.pop("ELECTRON_MCP_ENABLED", None) def test_browser_resolved_to_puppeteer_for_web_frontend(self): - """Browser should resolve to 'puppeteer' for web frontend projects.""" + """Browser should resolve to 'puppeteer' for web frontend projects when enabled.""" from agents.tools_pkg.models import get_required_mcp_servers + # Puppeteer requires explicit opt-in via project config servers = get_required_mcp_servers( - "qa_reviewer", project_capabilities={"is_web_frontend": True, "is_electron": False} + "qa_reviewer", + project_capabilities={"is_web_frontend": True, "is_electron": False}, + mcp_config={"PUPPETEER_MCP_ENABLED": "true"}, ) assert "puppeteer" in servers assert "browser" not in servers assert "electron" not in servers + def test_puppeteer_not_included_when_disabled(self): + """Puppeteer should NOT be included when not explicitly enabled (default).""" + from agents.tools_pkg.models import get_required_mcp_servers + + # Default behavior: puppeteer is NOT auto-enabled for web frontends + servers = get_required_mcp_servers( + "qa_reviewer", + project_capabilities={"is_web_frontend": True, "is_electron": False}, + ) + assert "puppeteer" not in servers + assert "browser" not in servers + class TestGetDefaultThinkingLevel: """Tests for get_default_thinking_level() function.""" diff --git a/tests/test_github_bot_detection.py b/tests/test_github_bot_detection.py new file mode 100644 index 00000000..2e9f6f3f --- /dev/null +++ b/tests/test_github_bot_detection.py @@ -0,0 +1,415 @@ +""" +Tests for Bot Detection Module +================================ + +Tests the BotDetector class to ensure it correctly prevents infinite loops. +""" + +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Add the backend runners/github directory to path +_backend_dir = Path(__file__).parent.parent / "apps" / "backend" +_github_dir = _backend_dir / "runners" / "github" +if str(_github_dir) not in sys.path: + sys.path.insert(0, str(_github_dir)) + +from bot_detection import BotDetectionState, BotDetector + + +@pytest.fixture +def temp_state_dir(tmp_path): + """Create temporary state directory.""" + state_dir = tmp_path / "github" + state_dir.mkdir() + return state_dir + + +@pytest.fixture +def mock_bot_detector(temp_state_dir): + """Create bot detector with mocked bot username.""" + with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"): + detector = BotDetector( + state_dir=temp_state_dir, + bot_token="fake-token", + review_own_prs=False, + ) + return detector + + +class TestBotDetectionState: + """Test BotDetectionState data class.""" + + def test_save_and_load(self, temp_state_dir): + """Test saving and loading state.""" + state = BotDetectionState( + reviewed_commits={ + "123": ["abc123", "def456"], + "456": ["ghi789"], + }, + last_review_times={ + "123": "2025-01-01T10:00:00", + "456": "2025-01-01T11:00:00", + }, + ) + + # Save + state.save(temp_state_dir) + + # Load + loaded = BotDetectionState.load(temp_state_dir) + + assert loaded.reviewed_commits == state.reviewed_commits + assert loaded.last_review_times == state.last_review_times + + def test_load_nonexistent(self, temp_state_dir): + """Test loading when file doesn't exist.""" + loaded = BotDetectionState.load(temp_state_dir) + + assert loaded.reviewed_commits == {} + assert loaded.last_review_times == {} + + +class TestBotDetectorInit: + """Test BotDetector initialization.""" + + def test_init_with_token(self, temp_state_dir): + """Test initialization with bot token.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps({"login": "my-bot"}), + ) + + detector = BotDetector( + state_dir=temp_state_dir, + bot_token="ghp_test123", + review_own_prs=False, + ) + + assert detector.bot_username == "my-bot" + assert detector.review_own_prs is False + + def test_init_without_token(self, temp_state_dir): + """Test initialization without bot token.""" + detector = BotDetector( + state_dir=temp_state_dir, + bot_token=None, + review_own_prs=True, + ) + + assert detector.bot_username is None + assert detector.review_own_prs is True + + +class TestBotDetection: + """Test bot detection methods.""" + + def test_is_bot_pr(self, mock_bot_detector): + """Test detecting bot-authored PRs.""" + bot_pr = {"author": {"login": "test-bot"}} + human_pr = {"author": {"login": "alice"}} + + assert mock_bot_detector.is_bot_pr(bot_pr) is True + assert mock_bot_detector.is_bot_pr(human_pr) is False + + def test_is_bot_commit(self, mock_bot_detector): + """Test detecting bot-authored commits.""" + bot_commit = {"author": {"login": "test-bot"}} + human_commit = {"author": {"login": "alice"}} + bot_committer = { + "committer": {"login": "test-bot"}, + "author": {"login": "alice"}, + } + + assert mock_bot_detector.is_bot_commit(bot_commit) is True + assert mock_bot_detector.is_bot_commit(human_commit) is False + assert mock_bot_detector.is_bot_commit(bot_committer) is True + + def test_get_last_commit_sha(self, mock_bot_detector): + """Test extracting last commit SHA.""" + # GitHub API returns commits in chronological order (oldest first, newest last) + # So commits[-1] is the LATEST commit + commits = [ + {"oid": "abc123"}, # Oldest commit + {"oid": "def456"}, # Latest commit + ] + + sha = mock_bot_detector.get_last_commit_sha(commits) + assert sha == "def456" # Should return the LAST (latest) commit + + # Test with sha field instead of oid + commits_with_sha = [{"sha": "xyz789"}] + sha = mock_bot_detector.get_last_commit_sha(commits_with_sha) + assert sha == "xyz789" + + # Empty commits + assert mock_bot_detector.get_last_commit_sha([]) is None + + +class TestCoolingOff: + """Test cooling off period. + + Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs. + """ + + def test_within_cooling_off(self, mock_bot_detector): + """Test PR within cooling off period.""" + # Set last review to 30 seconds ago (within 1 minute cooling off) + half_min_ago = datetime.now() - timedelta(seconds=30) + mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat() + + is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) + + assert is_cooling is True + assert "Cooling off" in reason + + def test_outside_cooling_off(self, mock_bot_detector): + """Test PR outside cooling off period.""" + # Set last review to 2 minutes ago (outside 1 minute cooling off) + two_min_ago = datetime.now() - timedelta(minutes=2) + mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat() + + is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) + + assert is_cooling is False + assert reason == "" + + def test_no_previous_review(self, mock_bot_detector): + """Test PR with no previous review.""" + is_cooling, reason = mock_bot_detector.is_within_cooling_off(999) + + assert is_cooling is False + assert reason == "" + + +class TestReviewedCommits: + """Test reviewed commit tracking.""" + + def test_has_reviewed_commit(self, mock_bot_detector): + """Test checking if commit was reviewed.""" + mock_bot_detector.state.reviewed_commits["123"] = ["abc123", "def456"] + + assert mock_bot_detector.has_reviewed_commit(123, "abc123") is True + assert mock_bot_detector.has_reviewed_commit(123, "xyz789") is False + assert mock_bot_detector.has_reviewed_commit(999, "abc123") is False + + def test_mark_reviewed(self, mock_bot_detector, temp_state_dir): + """Test marking PR as reviewed.""" + mock_bot_detector.mark_reviewed(123, "abc123") + + # Check state + assert "123" in mock_bot_detector.state.reviewed_commits + assert "abc123" in mock_bot_detector.state.reviewed_commits["123"] + assert "123" in mock_bot_detector.state.last_review_times + + # Check persistence + loaded = BotDetectionState.load(temp_state_dir) + assert "123" in loaded.reviewed_commits + assert "abc123" in loaded.reviewed_commits["123"] + + def test_mark_reviewed_multiple(self, mock_bot_detector): + """Test marking same PR reviewed multiple times.""" + mock_bot_detector.mark_reviewed(123, "abc123") + mock_bot_detector.mark_reviewed(123, "def456") + + commits = mock_bot_detector.state.reviewed_commits["123"] + assert len(commits) == 2 + assert "abc123" in commits + assert "def456" in commits + + +class TestShouldSkipReview: + """Test main should_skip_pr_review logic.""" + + def test_skip_bot_pr(self, mock_bot_detector): + """Test skipping bot-authored PR.""" + pr_data = {"author": {"login": "test-bot"}} + commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is True + assert "bot user" in reason + + def test_skip_bot_commit(self, mock_bot_detector): + """Test skipping PR with bot commit as the latest commit.""" + pr_data = {"author": {"login": "alice"}} + # GitHub API returns commits in chronological order (oldest first, newest last) + # So commits[-1] is the LATEST commit - which is the bot commit + commits = [ + {"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice) + {"author": {"login": "test-bot"}, "oid": "def456"}, # Latest commit (by bot) + ] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is True + assert "bot" in reason.lower() + + def test_skip_cooling_off(self, mock_bot_detector): + """Test skipping during cooling off period.""" + # Set last review to 30 seconds ago (within 1 minute cooling off) + half_min_ago = datetime.now() - timedelta(seconds=30) + mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat() + + pr_data = {"author": {"login": "alice"}} + commits = [{"author": {"login": "alice"}, "oid": "abc123"}] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is True + assert "Cooling off" in reason + + def test_skip_already_reviewed(self, mock_bot_detector): + """Test skipping already-reviewed commit.""" + mock_bot_detector.state.reviewed_commits["123"] = ["abc123"] + + pr_data = {"author": {"login": "alice"}} + commits = [{"author": {"login": "alice"}, "oid": "abc123"}] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is True + assert "Already reviewed" in reason + + def test_allow_review(self, mock_bot_detector): + """Test allowing review when all checks pass.""" + pr_data = {"author": {"login": "alice"}} + commits = [{"author": {"login": "alice"}, "oid": "abc123"}] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is False + assert reason == "" + + def test_allow_review_own_prs(self, temp_state_dir): + """Test allowing review when review_own_prs is True.""" + with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"): + detector = BotDetector( + state_dir=temp_state_dir, + bot_token="fake-token", + review_own_prs=True, # Allow bot to review own PRs + ) + + pr_data = {"author": {"login": "test-bot"}} + commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}] + + should_skip, reason = detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + # Should not skip even though it's bot's own PR + assert should_skip is False + + +class TestStateManagement: + """Test state management methods.""" + + def test_clear_pr_state(self, mock_bot_detector, temp_state_dir): + """Test clearing PR state.""" + # Set up state + mock_bot_detector.mark_reviewed(123, "abc123") + mock_bot_detector.mark_reviewed(456, "def456") + + # Clear one PR + mock_bot_detector.clear_pr_state(123) + + # Check in-memory state + assert "123" not in mock_bot_detector.state.reviewed_commits + assert "123" not in mock_bot_detector.state.last_review_times + assert "456" in mock_bot_detector.state.reviewed_commits + + # Check persistence + loaded = BotDetectionState.load(temp_state_dir) + assert "123" not in loaded.reviewed_commits + assert "456" in loaded.reviewed_commits + + def test_get_stats(self, mock_bot_detector): + """Test getting detector statistics.""" + mock_bot_detector.mark_reviewed(123, "abc123") + mock_bot_detector.mark_reviewed(123, "def456") + mock_bot_detector.mark_reviewed(456, "ghi789") + + stats = mock_bot_detector.get_stats() + + assert stats["bot_username"] == "test-bot" + assert stats["review_own_prs"] is False + assert stats["total_prs_tracked"] == 2 + assert stats["total_reviews_performed"] == 3 + assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_no_commits(self, mock_bot_detector): + """Test handling PR with no commits.""" + pr_data = {"author": {"login": "alice"}} + commits = [] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + # Should not skip (no bot commit to detect) + assert should_skip is False + + def test_malformed_commit_data(self, mock_bot_detector): + """Test handling malformed commit data.""" + pr_data = {"author": {"login": "alice"}} + commits = [ + {"author": {"login": "alice"}}, # Missing oid/sha + {}, # Empty commit + ] + + # Should not crash + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is False + + def test_invalid_last_review_time(self, mock_bot_detector): + """Test handling invalid timestamp in state.""" + mock_bot_detector.state.last_review_times["123"] = "invalid-timestamp" + + is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) + + # Should not crash, should return False + assert is_cooling is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_github_pr_e2e.py b/tests/test_github_pr_e2e.py new file mode 100644 index 00000000..6813513c --- /dev/null +++ b/tests/test_github_pr_e2e.py @@ -0,0 +1,478 @@ +""" +End-to-End Tests for GitHub PR Review System +============================================= + +Tests the full PR review flow with mocked external dependencies. +These tests validate the integration between components. +""" + +import asyncio +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch +from dataclasses import dataclass + +import pytest + +# Add the backend directory to path +_backend_dir = Path(__file__).parent.parent / "apps" / "backend" +_github_dir = _backend_dir / "runners" / "github" +if str(_github_dir) not in sys.path: + sys.path.insert(0, str(_github_dir)) +if str(_backend_dir) not in sys.path: + sys.path.insert(0, str(_backend_dir)) + +from models import ( + PRReviewResult, + PRReviewFinding, + ReviewSeverity, + ReviewCategory, + MergeVerdict, + GitHubRunnerConfig, + FollowupReviewContext, +) +from bot_detection import BotDetector + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def temp_github_dir(tmp_path): + """Create a temporary GitHub directory structure.""" + github_dir = tmp_path / ".auto-claude" / "github" + pr_dir = github_dir / "pr" + pr_dir.mkdir(parents=True) + return github_dir + + +@pytest.fixture +def mock_github_config(): + """Create a mock GitHub config.""" + return GitHubRunnerConfig( + repo="test-owner/test-repo", + token="ghp_test_token_12345", + model="claude-sonnet-4-20250514", + thinking_level="medium", + ) + + +@pytest.fixture +def sample_review_with_findings(): + """Create a sample review with findings.""" + return PRReviewResult( + pr_number=42, + repo="test-owner/test-repo", + success=True, + findings=[ + PRReviewFinding( + id="finding-001", + severity=ReviewSeverity.HIGH, + category=ReviewCategory.SECURITY, + title="SQL Injection vulnerability", + description="User input not sanitized", + file="src/db.py", + line=42, + suggested_fix="Use parameterized queries", + fixable=True, + ), + PRReviewFinding( + id="finding-002", + severity=ReviewSeverity.MEDIUM, + category=ReviewCategory.QUALITY, + title="Missing error handling", + description="Exception not caught", + file="src/api.py", + line=100, + suggested_fix="Add try-except block", + fixable=True, + ), + ], + summary="Found 2 issues: 1 high, 1 medium", + overall_status="request_changes", + verdict=MergeVerdict.NEEDS_REVISION, + verdict_reasoning="Security issues must be fixed", + reviewed_commit_sha="abc123def456", + reviewed_at=datetime.now().isoformat(), + has_posted_findings=True, + posted_finding_ids=["finding-001", "finding-002"], + ) + + +# ============================================================================ +# E2E Test: Review Result Persistence +# ============================================================================ + +class TestReviewResultE2E: + """Test review result save/load flow end-to-end.""" + + @pytest.mark.asyncio + async def test_save_load_review_with_findings(self, temp_github_dir, sample_review_with_findings): + """Test saving and loading a complete review result.""" + # Save the review + await sample_review_with_findings.save(temp_github_dir) + + # Verify file was created + review_file = temp_github_dir / "pr" / "review_42.json" + assert review_file.exists() + + # Load and verify + loaded = PRReviewResult.load(temp_github_dir, 42) + + assert loaded is not None + assert loaded.pr_number == 42 + assert loaded.success is True + assert len(loaded.findings) == 2 + assert loaded.findings[0].id == "finding-001" + assert loaded.findings[0].severity == ReviewSeverity.HIGH + assert loaded.findings[1].id == "finding-002" + assert loaded.reviewed_commit_sha == "abc123def456" + assert loaded.has_posted_findings is True + assert len(loaded.posted_finding_ids) == 2 + + @pytest.mark.asyncio + async def test_review_result_json_format(self, temp_github_dir, sample_review_with_findings): + """Test that saved JSON has correct format.""" + await sample_review_with_findings.save(temp_github_dir) + + review_file = temp_github_dir / "pr" / "review_42.json" + with open(review_file) as f: + data = json.load(f) + + # Verify key fields exist with snake_case + assert "pr_number" in data + assert "reviewed_commit_sha" in data + assert "has_posted_findings" in data + assert "posted_finding_ids" in data + assert data["pr_number"] == 42 + assert isinstance(data["findings"], list) + + +# ============================================================================ +# E2E Test: Follow-up Review Flow +# ============================================================================ + +class TestFollowupReviewE2E: + """Test follow-up review context and result flow.""" + + @pytest.mark.asyncio + async def test_followup_context_with_resolved_file( + self, temp_github_dir, sample_review_with_findings + ): + """Test follow-up when the file with finding was modified.""" + # Save previous review + await sample_review_with_findings.save(temp_github_dir) + + # Create follow-up context where the file was changed + context = FollowupReviewContext( + pr_number=42, + previous_review=sample_review_with_findings, + previous_commit_sha="abc123def456", + current_commit_sha="new_commit_sha", + files_changed_since_review=["src/db.py"], # File with finding-001 + diff_since_review="- unsanitized()\n+ parameterized()", + ) + + # Verify context + assert context.pr_number == 42 + assert "src/db.py" in context.files_changed_since_review + assert context.error is None + + # Simulate follow-up result (all issues resolved) + followup_result = PRReviewResult( + pr_number=42, + repo="test-owner/test-repo", + success=True, + findings=[], + summary="All previous issues resolved", + overall_status="approve", + verdict=MergeVerdict.READY_TO_MERGE, + is_followup_review=True, + resolved_findings=["finding-001"], + unresolved_findings=["finding-002"], # api.py wasn't changed + reviewed_commit_sha="new_commit_sha", + previous_review_id="42", + ) + + # Save and reload + await followup_result.save(temp_github_dir) + loaded = PRReviewResult.load(temp_github_dir, 42) + + assert loaded.is_followup_review is True + assert "finding-001" in loaded.resolved_findings + assert "finding-002" in loaded.unresolved_findings + + @pytest.mark.asyncio + async def test_followup_context_with_error(self, temp_github_dir, sample_review_with_findings): + """Test follow-up context when there's an error.""" + await sample_review_with_findings.save(temp_github_dir) + + # Create context with error + context = FollowupReviewContext( + pr_number=42, + previous_review=sample_review_with_findings, + previous_commit_sha="abc123", + current_commit_sha="def456", + error="Failed to compare commits: API rate limit", + ) + + assert context.error is not None + assert "rate limit" in context.error + + # Create error result + error_result = PRReviewResult( + pr_number=42, + repo="test-owner/test-repo", + success=False, + findings=[], + summary=f"Follow-up failed: {context.error}", + overall_status="comment", + error=context.error, + is_followup_review=True, + reviewed_commit_sha="def456", + ) + + assert error_result.success is False + assert error_result.error is not None + + +# ============================================================================ +# E2E Test: Bot Detection Flow +# ============================================================================ + +class TestBotDetectionE2E: + """Test bot detection end-to-end.""" + + def test_full_bot_detection_flow(self, tmp_path): + """Test complete bot detection workflow.""" + state_dir = tmp_path / "github" + state_dir.mkdir(parents=True) + + with patch.object(BotDetector, "_get_bot_username", return_value="auto-claude[bot]"): + detector = BotDetector( + state_dir=state_dir, + bot_token="ghp_bot_token", + review_own_prs=False, + ) + + # Scenario 1: Human PR, first review + pr_data = {"author": {"login": "human-dev"}} + commits = [{"author": {"login": "human-dev"}, "oid": "commit_1"}] + + should_skip, reason = detector.should_skip_pr_review( + pr_number=100, + pr_data=pr_data, + commits=commits, + ) + assert should_skip is False + + # Mark as reviewed + detector.mark_reviewed(100, "commit_1") + + # Scenario 2: Same commit, should skip after cooling off + # First, bypass cooling off by setting old timestamp + two_min_ago = datetime.now() - timedelta(minutes=2) + detector.state.last_review_times["100"] = two_min_ago.isoformat() + + should_skip, reason = detector.should_skip_pr_review( + pr_number=100, + pr_data=pr_data, + commits=commits, + ) + assert should_skip is True + assert "Already reviewed" in reason + + # Scenario 3: New commit on same PR + new_commits = [{"author": {"login": "human-dev"}, "oid": "commit_2"}] + should_skip, reason = detector.should_skip_pr_review( + pr_number=100, + pr_data=pr_data, + commits=new_commits, + ) + assert should_skip is False # New commit allows review + + # Scenario 4: Bot-authored PR + bot_pr = {"author": {"login": "auto-claude[bot]"}} + bot_commits = [{"author": {"login": "auto-claude[bot]"}, "oid": "bot_commit"}] + + should_skip, reason = detector.should_skip_pr_review( + pr_number=200, + pr_data=bot_pr, + commits=bot_commits, + ) + assert should_skip is True + assert "bot" in reason.lower() + + def test_bot_detection_state_persistence(self, tmp_path): + """Test that bot detection state persists across instances.""" + state_dir = tmp_path / "github" + state_dir.mkdir(parents=True) + + # First detector instance + with patch.object(BotDetector, "_get_bot_username", return_value="bot"): + detector1 = BotDetector(state_dir=state_dir, bot_token="token") + detector1.mark_reviewed(42, "abc123") + + # Second detector instance (simulating app restart) + with patch.object(BotDetector, "_get_bot_username", return_value="bot"): + detector2 = BotDetector(state_dir=state_dir, bot_token="token") + + # Should see the reviewed commit + assert detector2.has_reviewed_commit(42, "abc123") is True + + +# ============================================================================ +# E2E Test: Blocker Generation Flow +# ============================================================================ + +class TestBlockerGenerationE2E: + """Test blocker generation from findings.""" + + @pytest.mark.asyncio + async def test_blockers_generated_correctly(self, temp_github_dir): + """Test that blockers are generated from CRITICAL/HIGH findings.""" + findings = [ + PRReviewFinding( + id="critical-1", + severity=ReviewSeverity.CRITICAL, + category=ReviewCategory.SECURITY, + title="Remote Code Execution", + description="Critical security flaw", + file="src/exec.py", + line=1, + fixable=True, + ), + PRReviewFinding( + id="high-1", + severity=ReviewSeverity.HIGH, + category=ReviewCategory.QUALITY, + title="Memory Leak", + description="Resource not freed", + file="src/memory.py", + line=50, + fixable=True, + ), + PRReviewFinding( + id="low-1", + severity=ReviewSeverity.LOW, + category=ReviewCategory.STYLE, + title="Naming Convention", + description="Variable name not following style", + file="src/utils.py", + line=10, + fixable=True, + ), + ] + + # Generate blockers + blockers = [] + for finding in findings: + if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH): + blockers.append(f"{finding.category.value}: {finding.title}") + + # Create result with blockers + result = PRReviewResult( + pr_number=42, + repo="test/repo", + success=True, + findings=findings, + summary="Found 3 issues", + overall_status="request_changes", + verdict=MergeVerdict.NEEDS_REVISION, + blockers=blockers, + reviewed_commit_sha="abc123", + ) + + # Save and load + await result.save(temp_github_dir) + loaded = PRReviewResult.load(temp_github_dir, 42) + + assert len(loaded.blockers) == 2 + assert "security: Remote Code Execution" in loaded.blockers + assert "quality: Memory Leak" in loaded.blockers + + +# ============================================================================ +# E2E Test: Complete Review Lifecycle +# ============================================================================ + +class TestReviewLifecycleE2E: + """Test the complete review lifecycle.""" + + @pytest.mark.asyncio + async def test_initial_review_then_followup(self, temp_github_dir): + """Test complete flow: initial review -> post findings -> followup.""" + # Step 1: Initial review finds issues + initial_result = PRReviewResult( + pr_number=42, + repo="test/repo", + success=True, + findings=[ + PRReviewFinding( + id="issue-1", + severity=ReviewSeverity.HIGH, + category=ReviewCategory.SECURITY, + title="Security Issue", + description="Fix this", + file="src/auth.py", + line=100, + fixable=True, + ), + ], + summary="Found 1 issue", + overall_status="request_changes", + verdict=MergeVerdict.NEEDS_REVISION, + reviewed_commit_sha="commit_1", + reviewed_at=datetime.now().isoformat(), + ) + await initial_result.save(temp_github_dir) + + # Step 2: Post findings to GitHub (simulated) + initial_result.has_posted_findings = True + initial_result.posted_finding_ids = ["issue-1"] + initial_result.posted_at = datetime.now().isoformat() + await initial_result.save(temp_github_dir) + + # Verify posted state + loaded = PRReviewResult.load(temp_github_dir, 42) + assert loaded.has_posted_findings is True + + # Step 3: Contributor fixes the issue, new commit + followup_context = FollowupReviewContext( + pr_number=42, + previous_review=loaded, + previous_commit_sha="commit_1", + current_commit_sha="commit_2", + files_changed_since_review=["src/auth.py"], + diff_since_review="- vulnerable_code()\n+ secure_code()", + ) + + # Step 4: Follow-up review finds issue resolved + followup_result = PRReviewResult( + pr_number=42, + repo="test/repo", + success=True, + findings=[], + summary="All issues resolved", + overall_status="approve", + verdict=MergeVerdict.READY_TO_MERGE, + is_followup_review=True, + resolved_findings=["issue-1"], + unresolved_findings=[], + reviewed_commit_sha="commit_2", + previous_review_id="42", + ) + await followup_result.save(temp_github_dir) + + # Verify final state + final = PRReviewResult.load(temp_github_dir, 42) + assert final.is_followup_review is True + assert final.verdict == MergeVerdict.READY_TO_MERGE + assert "issue-1" in final.resolved_findings + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_github_pr_review.py b/tests/test_github_pr_review.py new file mode 100644 index 00000000..a602500a --- /dev/null +++ b/tests/test_github_pr_review.py @@ -0,0 +1,606 @@ +""" +Tests for GitHub PR Review System +================================== + +Tests the PR review orchestrator and follow-up review functionality. +""" + +import json +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch +from dataclasses import asdict + +import pytest + +# Add the backend directory to path +_backend_dir = Path(__file__).parent.parent / "apps" / "backend" +_github_dir = _backend_dir / "runners" / "github" +if str(_github_dir) not in sys.path: + sys.path.insert(0, str(_github_dir)) +if str(_backend_dir) not in sys.path: + sys.path.insert(0, str(_backend_dir)) + +from models import ( + PRReviewResult, + PRReviewFinding, + ReviewSeverity, + ReviewCategory, + MergeVerdict, + FollowupReviewContext, +) +from bot_detection import BotDetector, BotDetectionState + + +# ============================================================================ +# Fixtures +# ============================================================================ + +@pytest.fixture +def temp_github_dir(tmp_path): + """Create temporary GitHub directory structure.""" + github_dir = tmp_path / ".auto-claude" / "github" + pr_dir = github_dir / "pr" + pr_dir.mkdir(parents=True) + return github_dir + + +@pytest.fixture +def sample_finding(): + """Create a sample PR review finding.""" + return PRReviewFinding( + id="finding-001", + severity=ReviewSeverity.HIGH, + category=ReviewCategory.SECURITY, + title="SQL Injection vulnerability", + description="User input not sanitized", + file="src/db.py", + line=42, + suggested_fix="Use parameterized queries", + fixable=True, + ) + + +@pytest.fixture +def sample_review_result(sample_finding): + """Create a sample PR review result.""" + return PRReviewResult( + pr_number=123, + repo="test/repo", + success=True, + findings=[sample_finding], + summary="Found 1 security issue", + overall_status="request_changes", + verdict=MergeVerdict.NEEDS_REVISION, + verdict_reasoning="Security issues must be fixed", + reviewed_commit_sha="abc123def456", + reviewed_at=datetime.now().isoformat(), + ) + + +@pytest.fixture +def mock_bot_detector(tmp_path): + """Create a mock bot detector.""" + state_dir = tmp_path / "github" + state_dir.mkdir(parents=True) + + with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"): + detector = BotDetector( + state_dir=state_dir, + bot_token="fake-token", + review_own_prs=False, + ) + return detector + + +# ============================================================================ +# PRReviewResult Tests +# ============================================================================ + +class TestPRReviewResult: + """Test PRReviewResult model.""" + + def test_save_and_load(self, temp_github_dir, sample_review_result): + """Test saving and loading review result.""" + # Save + import asyncio + asyncio.get_event_loop().run_until_complete( + sample_review_result.save(temp_github_dir) + ) + + # Verify file exists + review_file = temp_github_dir / "pr" / f"review_{sample_review_result.pr_number}.json" + assert review_file.exists() + + # Load + loaded = PRReviewResult.load(temp_github_dir, sample_review_result.pr_number) + + assert loaded is not None + assert loaded.pr_number == sample_review_result.pr_number + assert loaded.success == sample_review_result.success + assert len(loaded.findings) == len(sample_review_result.findings) + assert loaded.reviewed_commit_sha == sample_review_result.reviewed_commit_sha + + def test_load_nonexistent(self, temp_github_dir): + """Test loading when file doesn't exist.""" + loaded = PRReviewResult.load(temp_github_dir, 999) + assert loaded is None + + def test_to_dict_camelcase(self, sample_review_result): + """Test that to_dict produces correct format.""" + data = sample_review_result.to_dict() + + # Should use snake_case for JSON serialization + assert "pr_number" in data + assert "reviewed_commit_sha" in data + assert "overall_status" in data + assert data["pr_number"] == 123 + + def test_from_dict_handles_snake_case(self, sample_review_result): + """Test that from_dict handles snake_case input.""" + data = { + "pr_number": 456, + "repo": "test/repo", + "success": True, + "findings": [], + "summary": "Test summary", + "overall_status": "approve", + "reviewed_commit_sha": "xyz789", + "reviewed_at": datetime.now().isoformat(), + } + + result = PRReviewResult.from_dict(data) + + assert result.pr_number == 456 + assert result.reviewed_commit_sha == "xyz789" + + +class TestPRReviewFinding: + """Test PRReviewFinding model.""" + + def test_finding_serialization(self, sample_finding): + """Test finding serialization to dict.""" + data = sample_finding.to_dict() + + assert data["id"] == "finding-001" + assert data["severity"] == "high" + assert data["category"] == "security" + assert data["file"] == "src/db.py" + assert data["line"] == 42 + + def test_finding_deserialization(self): + """Test finding deserialization from dict.""" + data = { + "id": "finding-002", + "severity": "critical", + "category": "quality", + "title": "Memory leak", + "description": "Resource not released", + "file": "src/memory.py", + "line": 100, + "suggested_fix": "Add cleanup code", + "fixable": True, + } + + finding = PRReviewFinding.from_dict(data) + + assert finding.id == "finding-002" + assert finding.severity == ReviewSeverity.CRITICAL + assert finding.category == ReviewCategory.QUALITY + + +# ============================================================================ +# Follow-up Review Context Tests +# ============================================================================ + +class TestFollowupReviewContext: + """Test FollowupReviewContext model.""" + + def test_context_with_changes(self, sample_review_result, sample_finding): + """Test follow-up context with file changes.""" + context = FollowupReviewContext( + pr_number=123, + previous_review=sample_review_result, + previous_commit_sha="abc123", + current_commit_sha="def456", + files_changed_since_review=["src/db.py", "src/api.py"], + diff_since_review="diff content here", + ) + + assert context.pr_number == 123 + assert context.previous_commit_sha == "abc123" + assert context.current_commit_sha == "def456" + assert len(context.files_changed_since_review) == 2 + assert context.error is None + + def test_context_with_error(self, sample_review_result): + """Test follow-up context with error flag.""" + context = FollowupReviewContext( + pr_number=123, + previous_review=sample_review_result, + previous_commit_sha="abc123", + current_commit_sha="def456", + error="Failed to compare commits: API error", + ) + + assert context.error is not None + assert "Failed to compare commits" in context.error + + +# ============================================================================ +# Bot Detection Integration Tests +# ============================================================================ + +class TestBotDetectionIntegration: + """Test bot detection integration with review flow.""" + + def test_already_reviewed_returns_skip(self, mock_bot_detector): + """Test that already reviewed commit returns skip.""" + from datetime import timedelta + + # Mark commit as reviewed + mock_bot_detector.mark_reviewed(123, "abc123def456") + + # Set last review time to 2 minutes ago to bypass cooling off (1 minute) + two_min_ago = datetime.now() - timedelta(minutes=2) + mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat() + + pr_data = {"author": {"login": "alice"}} + commits = [{"author": {"login": "alice"}, "oid": "abc123def456"}] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is True + assert "Already reviewed" in reason + + def test_new_commit_allows_review(self, mock_bot_detector): + """Test that new commit allows review.""" + from datetime import timedelta + + # Mark old commit as reviewed + mock_bot_detector.mark_reviewed(123, "old_commit_sha") + + # Set last review time to 2 minutes ago to bypass cooling off (1 minute) + two_min_ago = datetime.now() - timedelta(minutes=2) + mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat() + + pr_data = {"author": {"login": "alice"}} + # New commit - not yet reviewed + commits = [{"author": {"login": "alice"}, "oid": "new_commit_sha"}] + + should_skip, reason = mock_bot_detector.should_skip_pr_review( + pr_number=123, + pr_data=pr_data, + commits=commits, + ) + + assert should_skip is False + + +# ============================================================================ +# Orchestrator Skip Logic Tests +# ============================================================================ + +class TestOrchestratorSkipLogic: + """Test orchestrator behavior when bot detection skips.""" + + def test_skip_returns_existing_review(self, temp_github_dir, sample_review_result): + """Test that skipping 'Already reviewed' returns existing review.""" + import asyncio + + # Save existing review + asyncio.get_event_loop().run_until_complete( + sample_review_result.save(temp_github_dir) + ) + + # Simulate the orchestrator logic for "Already reviewed" skip + skip_reason = "Already reviewed commit abc123" + + # This is what the orchestrator should do: + if "Already reviewed" in skip_reason: + existing_review = PRReviewResult.load(temp_github_dir, 123) + assert existing_review is not None + assert existing_review.success is True + assert len(existing_review.findings) == 1 + # Existing review should be returned, not overwritten + + def test_skip_bot_pr_creates_skip_result(self, temp_github_dir): + """Test that skipping bot PR creates skip result.""" + skip_reason = "PR is authored by bot user test-bot" + + # For non-"Already reviewed" skips, create skip result + if "Already reviewed" not in skip_reason: + result = PRReviewResult( + pr_number=456, + repo="test/repo", + success=True, + findings=[], + summary=f"Skipped review: {skip_reason}", + overall_status="comment", + ) + + assert result.success is True + assert len(result.findings) == 0 + assert "bot user" in result.summary + + +# ============================================================================ +# Follow-up Review Logic Tests +# ============================================================================ + +class TestFollowupReviewLogic: + """Test follow-up review resolution logic.""" + + def test_finding_marked_resolved_when_file_changed(self): + """Test that findings are resolved when their files are changed.""" + # Finding in src/db.py at line 42 + finding = PRReviewFinding( + id="finding-001", + severity=ReviewSeverity.HIGH, + category=ReviewCategory.SECURITY, + title="SQL Injection", + description="Issue description", + file="src/db.py", + line=42, + fixable=True, + ) + + # File was changed + changed_files = ["src/db.py", "src/api.py"] + + # Simulate resolution check + file_was_changed = finding.file in changed_files + assert file_was_changed is True + + def test_finding_unresolved_when_file_not_changed(self): + """Test that findings are NOT resolved when files unchanged.""" + finding = PRReviewFinding( + id="finding-001", + severity=ReviewSeverity.HIGH, + category=ReviewCategory.SECURITY, + title="SQL Injection", + description="Issue description", + file="src/db.py", + line=42, + fixable=True, + ) + + # Different files changed + changed_files = ["src/api.py", "src/utils.py"] + + file_was_changed = finding.file in changed_files + assert file_was_changed is False + + def test_followup_result_tracks_resolution(self, sample_finding): + """Test that follow-up result correctly tracks resolution status.""" + result = PRReviewResult( + pr_number=123, + repo="test/repo", + success=True, + findings=[], # No new findings + summary="All issues resolved", + overall_status="approve", + verdict=MergeVerdict.READY_TO_MERGE, + is_followup_review=True, + resolved_findings=["finding-001"], + unresolved_findings=[], + new_findings_since_last_review=[], + ) + + assert result.is_followup_review is True + assert len(result.resolved_findings) == 1 + assert len(result.unresolved_findings) == 0 + assert result.verdict == MergeVerdict.READY_TO_MERGE + + +# ============================================================================ +# Posted Findings Tracking Tests +# ============================================================================ + +class TestPostedFindingsTracking: + """Test posted findings tracking for follow-up eligibility.""" + + def test_has_posted_findings_flag(self, sample_review_result): + """Test has_posted_findings flag tracking.""" + # Initially not posted + assert sample_review_result.has_posted_findings is False + + # After posting + sample_review_result.has_posted_findings = True + sample_review_result.posted_finding_ids = ["finding-001"] + sample_review_result.posted_at = datetime.now().isoformat() + + assert sample_review_result.has_posted_findings is True + assert len(sample_review_result.posted_finding_ids) == 1 + + def test_posted_findings_serialization(self, temp_github_dir, sample_review_result): + """Test that posted findings are serialized correctly.""" + import asyncio + + # Set posted findings + sample_review_result.has_posted_findings = True + sample_review_result.posted_finding_ids = ["finding-001"] + sample_review_result.posted_at = "2025-01-01T10:00:00" + + # Save + asyncio.get_event_loop().run_until_complete( + sample_review_result.save(temp_github_dir) + ) + + # Load and verify + loaded = PRReviewResult.load(temp_github_dir, sample_review_result.pr_number) + + assert loaded.has_posted_findings is True + assert loaded.posted_finding_ids == ["finding-001"] + assert loaded.posted_at == "2025-01-01T10:00:00" + + +# ============================================================================ +# Error Handling Tests +# ============================================================================ + +class TestErrorHandling: + """Test error handling in review flow.""" + + def test_context_gathering_error_propagates(self, sample_review_result): + """Test that context gathering errors are propagated.""" + context = FollowupReviewContext( + pr_number=123, + previous_review=sample_review_result, + previous_commit_sha="abc123", + current_commit_sha="def456", + error="Failed to compare commits: 404 Not Found", + ) + + # Orchestrator should check for error and handle appropriately + if context.error: + result = PRReviewResult( + pr_number=123, + repo="test/repo", + success=False, + findings=[], + summary=f"Follow-up review failed: {context.error}", + overall_status="comment", + error=context.error, + ) + + assert result.success is False + assert result.error is not None + assert "404" in result.error + + def test_invalid_finding_data_handled(self): + """Test that invalid finding data is handled gracefully.""" + invalid_data = { + "id": "finding-001", + "severity": "invalid_severity", # Invalid + "category": "security", + "title": "Test", + "description": "Test", + "file": "test.py", + "line": 1, + } + + # Should not crash, should use default or handle gracefully + try: + finding = PRReviewFinding.from_dict(invalid_data) + # If it doesn't raise, verify it handled the invalid data somehow + assert finding.id == "finding-001" + except (ValueError, KeyError): + # Expected for invalid severity + pass + + +# ============================================================================ +# Blocker Generation Tests +# ============================================================================ + +class TestBlockerGeneration: + """Test blocker generation from findings.""" + + def test_blockers_from_critical_findings(self): + """Test that blockers are generated from CRITICAL findings.""" + findings = [ + PRReviewFinding( + id="1", + severity=ReviewSeverity.CRITICAL, + category=ReviewCategory.SECURITY, + title="Critical Security Issue", + description="Desc", + file="a.py", + line=1, + fixable=True, + ), + PRReviewFinding( + id="2", + severity=ReviewSeverity.LOW, + category=ReviewCategory.STYLE, + title="Style Issue", + description="Desc", + file="b.py", + line=2, + fixable=True, + ), + ] + + # Generate blockers from CRITICAL/HIGH + blockers = [] + for finding in findings: + if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH): + blockers.append(f"{finding.category.value}: {finding.title}") + + assert len(blockers) == 1 + assert "security: Critical Security Issue" in blockers + + def test_blockers_from_high_findings(self): + """Test that blockers are generated from HIGH findings.""" + findings = [ + PRReviewFinding( + id="1", + severity=ReviewSeverity.HIGH, + category=ReviewCategory.QUALITY, + title="Memory Leak", + description="Desc", + file="a.py", + line=1, + fixable=True, + ), + PRReviewFinding( + id="2", + severity=ReviewSeverity.MEDIUM, + category=ReviewCategory.QUALITY, + title="Code Smell", + description="Desc", + file="b.py", + line=2, + fixable=True, + ), + ] + + blockers = [] + for finding in findings: + if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH): + blockers.append(f"{finding.category.value}: {finding.title}") + + assert len(blockers) == 1 + assert "quality: Memory Leak" in blockers + + def test_no_blockers_for_low_severity(self): + """Test that no blockers for LOW/MEDIUM findings.""" + findings = [ + PRReviewFinding( + id="1", + severity=ReviewSeverity.LOW, + category=ReviewCategory.STYLE, + title="Style Issue", + description="Desc", + file="a.py", + line=1, + fixable=True, + ), + PRReviewFinding( + id="2", + severity=ReviewSeverity.MEDIUM, + category=ReviewCategory.DOCS, + title="Missing Docs", + description="Desc", + file="b.py", + line=2, + fixable=True, + ), + ] + + blockers = [] + for finding in findings: + if finding.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH): + blockers.append(f"{finding.category.value}: {finding.title}") + + assert len(blockers) == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])