diff --git a/Pipfile b/Pipfile new file mode 100644 index 00000000..645a67ea --- /dev/null +++ b/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.12" diff --git a/src/forge/cli.py b/src/forge/cli.py index 36016aac..ee6c0502 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -662,56 +662,150 @@ async def cmd_project_setup(args: argparse.Namespace) -> int: await jira.close() -async def cmd_health(_args: argparse.Namespace) -> int: - """Check system health.""" - from forge.orchestrator.checkpointer import get_redis_client +async def cmd_health(args: argparse.Namespace) -> int: + """Check system health. + + Examples: + forge health + forge health --json + """ + import json - print("Checking system health...\n") + from forge.orchestrator.checkpointer import get_redis_client - # Check settings try: settings = get_settings() - print("[OK] Configuration loaded") - print(f" Jira: {settings.jira_base_url}") - print(f" Use labels: {settings.jira_use_labels}") - print(f" Store in comments: {settings.jira_store_in_comments}") except Exception as e: - print(f"[FAIL] Configuration: {e}") + error_msg = str(e) + if getattr(args, "json", False): + data = { + "status": "unhealthy", + "error": "Configuration error", + "details": error_msg, + } + sys.stdout.write(json.dumps(data) + "\n") + else: + print(f"Error: Configuration loading failed: {error_msg}", file=sys.stderr) return 1 - # Check Redis + # 1. Gather status details + redis_status = "connected" + redis_error = None try: redis_client = await get_redis_client() await redis_client.ping() - print(f"[OK] Redis connected: {settings.redis_url}") except Exception as e: - print(f"[FAIL] Redis: {e}") - return 1 + redis_status = "disconnected" + redis_error = str(e) - # Check Jira (if token configured) + jira_status = "skipped" + jira_error = None if settings.jira_api_token.get_secret_value() != "your-jira-api-token": try: from forge.integrations.jira.client import JiraClient jira = JiraClient() - # Try to get projects (simple API call) await jira.close() - print("[OK] Jira credentials configured") + jira_status = "configured" except Exception as e: - print(f"[WARN] Jira: {e}") + jira_status = "failed" + jira_error = str(e) + + # Resolve LLM backend + llm_backend = "unknown" + model = settings.llm_model + vertex_project = None + vertex_location = None + + provider = settings.detect_model_provider(settings.llm_model) + if provider == "google": + # Gemini is supported through Vertex AI, so a valid Gemini-on-Vertex configuration + # should be classified correctly under Vertex settings (if Vertex configuration is present), + # otherwise we classify it as direct Google GenAI. + # Direct Google GenAI (using google_api_key) is currently not supported at runtime by the agent. + if settings.anthropic_vertex_project_id and not settings.google_api_key.get_secret_value(): + llm_backend = "vertex-ai" + vertex_project = settings.anthropic_vertex_project_id or None + vertex_location = settings.anthropic_vertex_region or None + else: + llm_backend = "google-genai" + elif provider == "anthropic": + if settings.anthropic_api_key.get_secret_value(): + llm_backend = "anthropic" + else: + llm_backend = "vertex-ai" + vertex_project = settings.anthropic_vertex_project_id or None + vertex_location = settings.anthropic_vertex_region or None + + # Map status + if redis_status == "disconnected": + overall_status = "unhealthy" + elif ( + llm_backend == "vertex-ai" + and not vertex_project + or llm_backend + == "google-genai" # google-genai is direct Google GenAI using google_api_key, which is not supported at runtime by the agent, so should not report "healthy" + or llm_backend == "anthropic" + and not settings.anthropic_api_key.get_secret_value() + or llm_backend == "unknown" + ): + overall_status = "warning" else: - print("[SKIP] Jira: API token not configured") + overall_status = "healthy" + + # 2. Render output + if getattr(args, "json", False): + data = { + "status": overall_status, + "redis": { + "status": redis_status, + "error": redis_error, + }, + "jira": { + "status": jira_status, + "error": jira_error, + }, + "llm": { + "backend": llm_backend, + "model": model, + "vertex_project": vertex_project, + "vertex_location": vertex_location, + }, + } + sys.stdout.write(json.dumps(data) + "\n") + return 1 if overall_status == "unhealthy" else 0 - # Check Anthropic/Vertex - if settings.use_vertex_ai: - print(f"[OK] Using Vertex AI: {settings.anthropic_vertex_project_id}") - elif settings.anthropic_api_key.get_secret_value(): - print("[OK] Using direct Anthropic API") else: - print("[WARN] No Claude API configured") + print("Checking system health...\n") + print("[OK] Configuration loaded") + print(f" Jira: {settings.jira_base_url}") + print(f" Use labels: {settings.jira_use_labels}") + print(f" Store in comments: {settings.jira_store_in_comments}") - print("\nHealth check complete!") - return 0 + if redis_status == "connected": + print(f"[OK] Redis connected: {settings.redis_url}") + else: + print(f"[FAIL] Redis: {redis_error}") + return 1 + + if jira_status == "configured": + print("[OK] Jira credentials configured") + elif jira_status == "failed": + print(f"[WARN] Jira: {jira_error}") + else: + print("[SKIP] Jira: API token not configured") + + if llm_backend == "vertex-ai": + print(f"[OK] Using Vertex AI: {vertex_project}") + elif llm_backend == "anthropic": + print("[OK] Using direct Anthropic API") + elif llm_backend == "google-genai": + print("[OK] Using Google GenAI API") + else: + print("[WARN] No Claude API configured") + + print("\nHealth check complete!") + return 0 def main() -> int: @@ -797,10 +891,15 @@ def main() -> int: clear_parser.add_argument("ticket", help="Jira ticket key") # health command - subparsers.add_parser( + health_parser = subparsers.add_parser( "health", help="Check system health", ) + health_parser.add_argument( + "--json", + action="store_true", + help="Output health check results as a single JSON object", + ) # list command subparsers.add_parser( diff --git a/src/forge/config.py b/src/forge/config.py index e1bc7db9..c20945b9 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -162,6 +162,11 @@ def known_repos(self) -> list[str]: default="us-east5", description="Google Cloud region for Vertex AI (e.g., us-east5)", ) + # Option 3: Google GenAI API + google_api_key: SecretStr = Field( + default=SecretStr(""), + description="Google API key for Gemini models", + ) # Model configuration (supports Claude and Gemini on Vertex AI) # Claude models: claude-opus-4-5@20251101, claude-sonnet-4-5@20250929, etc. # Gemini models: gemini-2.5-pro, gemini-2.5-flash, gemini-3.1-pro-preview, etc. diff --git a/tests/unit/test_cli_health.py b/tests/unit/test_cli_health.py new file mode 100644 index 00000000..b0d8888c --- /dev/null +++ b/tests/unit/test_cli_health.py @@ -0,0 +1,221 @@ +"""Unit tests for the CLI health command.""" + +import argparse +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from forge.cli import cmd_health +from forge.config import Settings + + +@pytest.mark.asyncio +async def test_health_configured_vertex_ai(): + """Verify that a correctly configured Vertex AI environment outputs healthy status and correct parameters.""" + test_settings = Settings( + redis_url="redis://localhost:6379/0", + jira_base_url="https://test.atlassian.net", + jira_api_token="test-token", + jira_user_email="test@example.com", + github_token="test-github-token", + anthropic_vertex_project_id="test-project", + anthropic_vertex_region="us-central1", + anthropic_api_key="", + llm_model="claude-sonnet-4-5@20250929", + ) + + mock_redis_client = MagicMock() + mock_redis_client.ping = AsyncMock(return_value=True) + + with ( + patch("forge.cli.get_settings", return_value=test_settings), + patch("forge.orchestrator.checkpointer.get_redis_client", return_value=mock_redis_client), + patch("sys.stdout.write") as mock_stdout_write, + ): + args = argparse.Namespace(json=True) + exit_code = await cmd_health(args) + + assert exit_code == 0 + written = "".join(call.args[0] for call in mock_stdout_write.call_args_list) + data = json.loads(written) + + assert data["status"] == "healthy" + assert data["redis"]["status"] == "connected" + assert data["llm"]["backend"] == "vertex-ai" + assert data["llm"]["vertex_project"] == "test-project" + assert data["llm"]["vertex_location"] == "us-central1" + + +@pytest.mark.asyncio +async def test_health_missing_vertex_project(): + """Verify that an environment with Vertex AI backend but missing project returns warning.""" + test_settings = Settings( + redis_url="redis://localhost:6379/0", + jira_base_url="https://test.atlassian.net", + jira_api_token="test-token", + jira_user_email="test@example.com", + github_token="test-github-token", + anthropic_vertex_project_id="", + anthropic_vertex_region="us-central1", + anthropic_api_key="", + llm_model="claude-sonnet-4-5@20250929", + ) + + mock_redis_client = MagicMock() + mock_redis_client.ping = AsyncMock(return_value=True) + + with ( + patch("forge.cli.get_settings", return_value=test_settings), + patch("forge.orchestrator.checkpointer.get_redis_client", return_value=mock_redis_client), + patch("sys.stdout.write") as mock_stdout_write, + ): + args = argparse.Namespace(json=True) + exit_code = await cmd_health(args) + + assert exit_code == 0 # Should be 0 since warning, not unhealthy + written = "".join(call.args[0] for call in mock_stdout_write.call_args_list) + data = json.loads(written) + + assert data["status"] == "warning" + assert data["llm"]["backend"] == "vertex-ai" + assert data["llm"]["vertex_project"] is None + + +@pytest.mark.asyncio +async def test_health_gemini_api(): + """Verify that Google GenAI / Gemini configuration outputs warning status since direct Google GenAI is not supported at runtime yet.""" + test_settings = Settings( + redis_url="redis://localhost:6379/0", + jira_base_url="https://test.atlassian.net", + jira_api_token="test-token", + jira_user_email="test@example.com", + github_token="test-github-token", + google_api_key="valid-key", + llm_model="gemini-2.5-pro", + anthropic_api_key="", + ) + + mock_redis_client = MagicMock() + mock_redis_client.ping = AsyncMock(return_value=True) + + with ( + patch("forge.cli.get_settings", return_value=test_settings), + patch("forge.orchestrator.checkpointer.get_redis_client", return_value=mock_redis_client), + patch("sys.stdout.write") as mock_stdout_write, + ): + args = argparse.Namespace(json=True) + exit_code = await cmd_health(args) + + assert exit_code == 0 + written = "".join(call.args[0] for call in mock_stdout_write.call_args_list) + data = json.loads(written) + + assert data["status"] == "warning" + assert data["llm"]["backend"] == "google-genai" + + +@pytest.mark.asyncio +async def test_health_anthropic_api(): + """Verify that Anthropic API configuration works and outputs healthy status.""" + test_settings = Settings( + redis_url="redis://localhost:6379/0", + jira_base_url="https://test.atlassian.net", + jira_api_token="test-token", + jira_user_email="test@example.com", + github_token="test-github-token", + anthropic_api_key="valid-key", + llm_model="claude-sonnet-4-5@20250929", + ) + + mock_redis_client = MagicMock() + mock_redis_client.ping = AsyncMock(return_value=True) + + with ( + patch("forge.cli.get_settings", return_value=test_settings), + patch("forge.orchestrator.checkpointer.get_redis_client", return_value=mock_redis_client), + patch("sys.stdout.write") as mock_stdout_write, + ): + args = argparse.Namespace(json=True) + exit_code = await cmd_health(args) + + assert exit_code == 0 + written = "".join(call.args[0] for call in mock_stdout_write.call_args_list) + data = json.loads(written) + + assert data["status"] == "healthy" + assert data["llm"]["backend"] == "anthropic" + + +@pytest.mark.asyncio +async def test_health_redis_failure(): + """Verify that a Redis connection failure outputs disconnected and unhealthy status with exit code 1.""" + test_settings = Settings( + redis_url="redis://localhost:6379/0", + jira_base_url="https://test.atlassian.net", + jira_api_token="test-token", + jira_user_email="test@example.com", + github_token="test-github-token", + anthropic_api_key="valid-key", + llm_model="claude-sonnet-4-5@20250929", + ) + + mock_redis_client = MagicMock() + mock_redis_client.ping = AsyncMock(side_effect=Exception("Redis connection timed out")) + + with ( + patch("forge.cli.get_settings", return_value=test_settings), + patch("forge.orchestrator.checkpointer.get_redis_client", return_value=mock_redis_client), + patch("sys.stdout.write") as mock_stdout_write, + ): + args = argparse.Namespace(json=True) + exit_code = await cmd_health(args) + + assert exit_code == 1 + written = "".join(call.args[0] for call in mock_stdout_write.call_args_list) + data = json.loads(written) + + assert data["status"] == "unhealthy" + assert data["redis"]["status"] == "disconnected" + assert "Redis connection timed out" in data["redis"]["error"] + + +@pytest.mark.asyncio +async def test_health_secret_exclusion(capsys): + """Ensure that secrets never appear in raw outputs in both JSON and text mode.""" + secret_token = "ultra-secret-token-123456" + test_settings = Settings( + redis_url="redis://localhost:6379/0", + jira_base_url="https://test.atlassian.net", + jira_api_token=secret_token, + jira_user_email="test@example.com", + github_token=secret_token, + anthropic_api_key=secret_token, + google_api_key=secret_token, + llm_model="claude-sonnet-4-5@20250929", + ) + + mock_redis_client = MagicMock() + mock_redis_client.ping = AsyncMock(return_value=True) + + # 1. Test JSON Mode + with ( + patch("forge.cli.get_settings", return_value=test_settings), + patch("forge.orchestrator.checkpointer.get_redis_client", return_value=mock_redis_client), + patch("sys.stdout.write") as mock_stdout_write, + ): + args = argparse.Namespace(json=True) + await cmd_health(args) + written = "".join(call.args[0] for call in mock_stdout_write.call_args_list) + assert secret_token not in written + + # 2. Test Text Mode + with ( + patch("forge.cli.get_settings", return_value=test_settings), + patch("forge.orchestrator.checkpointer.get_redis_client", return_value=mock_redis_client), + ): + args = argparse.Namespace(json=False) + await cmd_health(args) + captured = capsys.readouterr() + assert secret_token not in captured.out + assert secret_token not in captured.err diff --git a/tests/unit/test_cli_health_config_error.py b/tests/unit/test_cli_health_config_error.py new file mode 100644 index 00000000..1bce0f75 --- /dev/null +++ b/tests/unit/test_cli_health_config_error.py @@ -0,0 +1,35 @@ +"""Unit tests for the CLI health command configuration error handling.""" + +import argparse +import json +from unittest.mock import patch + +import pytest + +from forge.cli import cmd_health + + +@pytest.mark.asyncio +async def test_health_config_error_json(): + """Verify that a configuration exception during get_settings outputs valid JSON structure with status unhealthy and exits with 1.""" + with patch("forge.cli.get_settings", side_effect=ValueError("Invalid config fields")): + args = argparse.Namespace(json=True) + with patch("sys.stdout.write") as mock_stdout_write: + exit_code = await cmd_health(args) + assert exit_code == 1 + written = "".join(call.args[0] for call in mock_stdout_write.call_args_list) + data = json.loads(written) + assert data["status"] == "unhealthy" + assert data["error"] == "Configuration error" + assert "Invalid config fields" in data["details"] + + +@pytest.mark.asyncio +async def test_health_config_error_text(capsys): + """Verify that a configuration exception in text mode outputs cleanly to stderr and exits with 1.""" + with patch("forge.cli.get_settings", side_effect=ValueError("Invalid config fields")): + args = argparse.Namespace(json=False) + exit_code = await cmd_health(args) + assert exit_code == 1 + captured = capsys.readouterr() + assert "Error: Configuration loading failed: Invalid config fields" in captured.err