diff --git a/PRPs/features/completed/add-health-check-command.md b/PRPs/features/completed/add-health-check-command.md new file mode 100644 index 0000000..7a1427e --- /dev/null +++ b/PRPs/features/completed/add-health-check-command.md @@ -0,0 +1,639 @@ +# Feature: Health Check Command + +## Feature Description + +Add a `dylan health` command to the Dylan CLI that performs comprehensive system diagnostics and health checks on the development environment. This command will verify that all required dependencies (Git, GitHub CLI, Claude Code) are properly installed and configured, check the current project state, and provide actionable feedback to users about any issues that need attention. + +The health check command will serve as a first-stop diagnostic tool for users experiencing issues with Dylan utilities, reducing troubleshooting time and improving the overall user experience. It will display results in a visually appealing format using Rich's table and panel components, consistent with Dylan's existing UI theme. + +## User Story + +As a developer using Dylan CLI tools +I want to run a health check command +So that I can quickly verify my environment is properly configured and identify any issues before running other Dylan commands + +## Problem Statement + +Users of Dylan CLI often encounter issues due to: +- Missing or improperly configured dependencies (Git, GitHub CLI, Claude Code) +- Incorrect repository state or branch configuration +- Authentication issues with GitHub or Claude Code +- Misconfigured project settings or missing required files + +Currently, there is no unified way to diagnose these issues. Users must manually check each dependency and configuration, which is time-consuming and error-prone. This leads to frustration and support burden when commands fail due to environment issues. + +## Solution Statement + +Implement a `dylan health` CLI command that automatically checks all critical dependencies and configuration requirements. The command will: + +1. Verify system dependencies (Git, gh CLI, Claude Code) are installed and accessible +2. Check project configuration (git repo, branch status, remote configuration) +3. Validate authentication status for GitHub and Claude Code +4. Test basic functionality of each dependency +5. Display results in a clear, color-coded format with actionable recommendations +6. Return appropriate exit codes for scripting and CI/CD integration + +The implementation will follow Dylan's existing patterns for CLI commands, using Typer for argument parsing, Rich for UI presentation, and consistent error handling. + +## Relevant Files + +### Existing Files to Reference + +- **dylan/cli.py** (lines 1-84) + - Main CLI entry point where the health command will be registered + - Shows pattern for adding commands with `@app.command()` decorator + - Demonstrates use of UI theme constants for consistent styling + +- **dylan/utility_library/shared/config.py** (lines 1-21) + - Configuration constants including repository URLs and dependency package names + - Defines error messages for missing dependencies + - Will be extended with health check related constants + +- **dylan/utility_library/shared/ui_theme.py** + - UI theme components (COLORS, ARROW, SPARK) for consistent look and feel + - Provides color constants for success, error, warning, and info states + - Will be used for health check result visualization + +- **dylan/utility_library/shared/error_handling.py** + - Error handling patterns with `@handle_dylan_errors()` decorator + - User-friendly error messages with context + - Pattern to follow for health check error scenarios + +- **dylan/utility_library/provider_clis/provider_claude_code.py** + - Shows how to check for Claude Code installation + - Provides subprocess execution patterns for running external commands + - Will be referenced for Claude Code health checks + +- **dylan/utility_library/provider_clis/shared/subprocess_utils.py** + - Subprocess utilities for running external commands + - Error handling for command execution + - Will be used for dependency version checks + +- **dylan/utility_library/dylan_review/dylan_review_cli.py** + - Example CLI interface implementation pattern + - Shows integration with shared UI components + - Demonstrates proper use of Typer options and arguments + +- **dylan/utility_library/dylan_standup/standup_cli.py** + - Another CLI command implementation for reference + - Shows pattern for commands with options and flags + - Demonstrates integration with error handling decorator + +### New Files + +#### Core Implementation + +- **dylan/utility_library/dylan_health/__init__.py** + - Package initialization for health check module + - Exports public API + +- **dylan/utility_library/dylan_health/dylan_health_cli.py** + - CLI interface implementation + - Command definition with Typer + - Options for verbose output, JSON format, etc. + +- **dylan/utility_library/dylan_health/dylan_health_runner.py** + - Core health check logic + - Dependency verification functions + - Project state validation + - Result aggregation and reporting + +- **dylan/utility_library/dylan_health/checks.py** + - Individual health check functions + - Each check returns standardized result object + - Checks include: Git, GitHub CLI, Claude Code, repository state, authentication + +#### Testing + +- **dylan/utility_library/dylan_health/tests/__init__.py** + - Test package initialization + +- **dylan/utility_library/dylan_health/tests/conftest.py** + - Pytest fixtures for health check tests + - Mock dependency executables + - Mock git repository states + +- **dylan/utility_library/dylan_health/tests/test_dylan_health_cli.py** + - CLI interface tests + - Command invocation tests + - Option/flag tests + +- **dylan/utility_library/dylan_health/tests/test_dylan_health_runner.py** + - Core logic tests + - Result aggregation tests + - Error handling tests + +- **dylan/utility_library/dylan_health/tests/test_checks.py** + - Unit tests for individual health checks + - Mock subprocess calls + - Test all pass/fail scenarios + +## Relevant Research & Documentation + +Use these documentation files and links to help with understanding the technology to use: + +- [Typer - Python CLI Framework](https://typer.tiangolo.com/) + - [Commands and Arguments](https://typer.tiangolo.com/tutorial/commands/) + - [Options and Flags](https://typer.tiangolo.com/tutorial/options/) + - [Testing Typer Applications](https://typer.tiangolo.com/tutorial/testing/) + - Building CLI commands with type hints, automatic help generation, and parameter validation + +- [Rich - Python Terminal Formatting](https://rich.readthedocs.io/) + - [Tables](https://rich.readthedocs.io/en/stable/tables.html) + - [Panels](https://rich.readthedocs.io/en/stable/panel.html) + - [Console Status](https://rich.readthedocs.io/en/stable/console.html#status) + - Beautiful terminal output with colors, tables, progress indicators + +- [GitPython Documentation](https://gitpython.readthedocs.io/) + - [Repository](https://gitpython.readthedocs.io/en/stable/reference.html#git.repo.base.Repo) + - [Remote Operations](https://gitpython.readthedocs.io/en/stable/reference.html#git.remote.Remote) + - Checking git repository state, branch info, remote configuration + +- [Pytest Documentation](https://docs.pytest.org/) + - [Fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html) + - [Mocking](https://docs.pytest.org/en/stable/how-to/monkeypatch.html) + - [Parametrize](https://docs.pytest.org/en/stable/how-to/parametrize.html) + - Testing patterns and best practices + +- [Python subprocess module](https://docs.python.org/3/library/subprocess.html) + - [subprocess.run()](https://docs.python.org/3/3/library/subprocess.html#subprocess.run) + - [Handling exceptions](https://docs.python.org/3/library/subprocess.html#subprocess.CalledProcessError) + - Running external commands and capturing output + +- [CLI Health Check Best Practices (2025)](https://betterstack.com/community/guides/monitoring/kubernetes-health-checks/) + - Clear status codes: 0 for healthy, non-zero for unhealthy + - Dedicated endpoints for different check types + - Appropriate timeouts and retry logic + - Comprehensive yet concise output + +## Implementation Plan + +### Phase 1: Foundation + +**Setup Module Structure** +- Create `dylan/utility_library/dylan_health/` directory +- Create `__init__.py` with package docstring +- Create empty `checks.py` for individual check functions +- Create empty `dylan_health_runner.py` for core logic +- Create empty `dylan_health_cli.py` for CLI interface + +**Define Data Models** +- Create Pydantic models or dataclasses for health check results +- Define `HealthCheckResult` with fields: name, status (PASS/FAIL/WARN), message, details +- Define `HealthCheckReport` to aggregate all check results +- Ensure type safety with proper type hints + +### Phase 2: Core Implementation + +**Implement Individual Health Checks** (in `checks.py`) +- `check_git_installed()`: Verify git is installed and get version +- `check_git_repo()`: Verify current directory is a git repository +- `check_git_branch()`: Check current branch and clean/dirty state +- `check_git_remote()`: Verify remote repository is configured +- `check_gh_cli_installed()`: Verify GitHub CLI is installed and authenticated +- `check_claude_code_installed()`: Verify Claude Code is installed and get version +- `check_project_structure()`: Verify expected project files exist (pyproject.toml, etc.) +- Each function returns a `HealthCheckResult` object with standardized format + +**Implement Core Runner Logic** (in `dylan_health_runner.py`) +- `run_all_checks()`: Execute all health checks in sequence +- `run_specific_check()`: Execute individual check by name (for future extensibility) +- Aggregate results into `HealthCheckReport` +- Determine overall health status (all pass = healthy, any fail = unhealthy) +- Format results for display using Rich components + +**Implement CLI Interface** (in `dylan_health_cli.py`) +- Define `health()` command function with Typer +- Add `--verbose` flag for detailed output +- Add `--json` flag for machine-readable output +- Add `--check` option to run specific checks only +- Call runner and display results using Rich +- Return appropriate exit codes (0 = healthy, 1 = unhealthy) +- Apply `@handle_dylan_errors()` decorator for error handling + +**Register Command in Main CLI** (update `dylan/cli.py`) +- Import `health` command from `dylan_health_cli` +- Add command to app with `@app.command()` +- Add to help table in main callback +- Ensure consistent styling with existing commands + +### Phase 3: Integration + +**Testing Setup** +- Create test directory structure: `dylan/utility_library/dylan_health/tests/` +- Create `conftest.py` with fixtures for mocking dependencies +- Fixtures: `mock_git_repo`, `mock_subprocess`, `mock_claude_provider`, etc. + +**Add Configuration Constants** (update `dylan/utility_library/shared/config.py`) +- Add health check related messages +- Add dependency version requirements if needed +- Document expected environment variables + +**CLI Integration** +- Test command invocation through main CLI +- Verify help text displays correctly +- Test with various option combinations +- Ensure consistent UI theme with other commands + +**Documentation** +- Add usage examples to CLAUDE.md +- Document all health check types +- Document exit codes and their meanings +- Add troubleshooting guide for common failures + +## Step by Step Tasks + +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Create Module Structure + +- Create directory: `dylan/utility_library/dylan_health/` +- Create `dylan/utility_library/dylan_health/__init__.py` with module docstring +- Create empty `dylan/utility_library/dylan_health/checks.py` with module docstring +- Create empty `dylan/utility_library/dylan_health/dylan_health_runner.py` with module docstring +- Create empty `dylan/utility_library/dylan_health/dylan_health_cli.py` with module docstring + +### 2. Define Data Models and Types + +- In `checks.py`, define `HealthCheckStatus` enum (PASS, FAIL, WARN) +- Define `HealthCheckResult` dataclass with fields: name, status, message, details (optional) +- Add type hints for all fields +- Add Google-style docstrings + +### 3. Implement Individual Health Checks + +- In `checks.py`, implement `check_git_installed() -> HealthCheckResult` + - Run `git --version` subprocess + - Parse version output + - Return PASS with version or FAIL with error message + - Add structured logging with correlation context + - Add type hints and docstring + +- Implement `check_git_repo() -> HealthCheckResult` + - Use GitPython to check if current directory is a repo + - Return PASS if repo exists, FAIL otherwise + - Include repository path in details + - Add structured logging and type hints + +- Implement `check_git_branch() -> HealthCheckResult` + - Get current branch name + - Check if working tree is clean + - Return status with branch info and clean/dirty state + - Add structured logging and type hints + +- Implement `check_git_remote() -> HealthCheckResult` + - Check if remote 'origin' exists + - Get remote URL + - Return PASS with remote info or FAIL if no remote + - Add structured logging and type hints + +- Implement `check_gh_cli_installed() -> HealthCheckResult` + - Run `gh --version` subprocess + - Check authentication status with `gh auth status` + - Return PASS if installed and authenticated, WARN if not authenticated, FAIL if not installed + - Add structured logging and type hints + +- Implement `check_claude_code_installed() -> HealthCheckResult` + - Run `claude --version` subprocess + - Parse version output + - Return PASS with version or FAIL with installation instructions + - Add structured logging and type hints + +- Implement `check_project_structure() -> HealthCheckResult` + - Check for existence of `pyproject.toml`, `README.md`, `.git/` + - Return PASS if all exist, WARN if some missing + - Include list of missing files in details + - Add structured logging and type hints + +### 4. Implement Runner Logic + +- In `dylan_health_runner.py`, import all check functions from `checks.py` +- Define `HealthCheckReport` dataclass to hold list of results and overall status +- Implement `run_all_checks() -> HealthCheckReport` + - Execute all check functions in sequence + - Collect all results + - Determine overall health status (any FAIL = unhealthy) + - Return aggregated report + - Add structured logging with correlation context + - Add type hints and Google-style docstring + +- Implement `format_results_table(report: HealthCheckReport) -> Table` + - Create Rich Table with columns: Check, Status, Details + - Add rows for each check result + - Use color coding: green for PASS, red for FAIL, yellow for WARN + - Return formatted table + - Add type hints and docstring + +- Implement `format_results_json(report: HealthCheckReport) -> str` + - Convert report to JSON format for machine consumption + - Include all check details + - Return JSON string + - Add type hints and docstring + +### 5. Implement CLI Interface + +- In `dylan_health_cli.py`, import Typer, Rich Console, and runner functions +- Import UI theme constants from `shared/ui_theme.py` +- Import error handling decorator from `shared/error_handling.py` +- Define `health()` command function with Typer decorators: + - Add `verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output")` + - Add `json_output: bool = typer.Option(False, "--json", help="Output results in JSON format")` + - Add proper type hints with return type `None` +- Implement function body: + - Call `run_all_checks()` from runner + - If `json_output`, print JSON and exit + - Otherwise, display results using Rich table + - Show summary message (overall status) + - Exit with code 0 if healthy, 1 if unhealthy + - Apply `@handle_dylan_errors(utility_name="health")` decorator + - Add Google-style docstring + +### 6. Register Command in Main CLI + +- Edit `dylan/cli.py` +- Import health command: `from .utility_library.dylan_health.dylan_health_cli import health` +- Add command registration: `app.command(name="health", help="Check system health and dependencies")(health)` +- In the `_main()` callback, add row to help table: + ```python + table.add_row( + "health", + "Check system health and dependencies", + "dylan health --verbose" + ) + ``` +- Ensure proper ordering in the table (suggest after standup, before review) + +### 7. Create Test Structure + +- Create directory: `dylan/utility_library/dylan_health/tests/` +- Create `dylan/utility_library/dylan_health/tests/__init__.py` (empty) +- Create `dylan/utility_library/dylan_health/tests/conftest.py` with fixtures: + - `@pytest.fixture mock_git_command()` - mock subprocess for git commands + - `@pytest.fixture mock_gh_command()` - mock subprocess for gh commands + - `@pytest.fixture mock_claude_command()` - mock subprocess for claude commands + - `@pytest.fixture temp_git_repo()` - create temporary git repository for testing + - All fixtures must have type hints and docstrings + +### 8. Write Unit Tests for Health Checks + +- Create `dylan/utility_library/dylan_health/tests/test_checks.py` +- Import pytest and check functions +- Write tests for each check function: + - `test_check_git_installed_success()` - git is installed and returns version + - `test_check_git_installed_failure()` - git command not found + - `test_check_git_repo_success()` - current directory is a git repo + - `test_check_git_repo_failure()` - current directory is not a git repo + - `test_check_git_branch_clean()` - working tree is clean + - `test_check_git_branch_dirty()` - working tree has uncommitted changes + - `test_check_git_remote_exists()` - remote origin is configured + - `test_check_git_remote_missing()` - no remote configured + - `test_check_gh_cli_installed_and_authenticated()` - gh CLI installed and logged in + - `test_check_gh_cli_installed_not_authenticated()` - gh CLI installed but not logged in + - `test_check_gh_cli_not_installed()` - gh CLI not found + - `test_check_claude_code_installed_success()` - claude installed with version + - `test_check_claude_code_not_installed()` - claude command not found + - `test_check_project_structure_complete()` - all required files exist + - `test_check_project_structure_missing_files()` - some files missing +- Use appropriate fixtures and mocking +- Mark all tests with `@pytest.mark.unit` +- All tests must have type hints + +### 9. Write Tests for Runner Logic + +- Create `dylan/utility_library/dylan_health/tests/test_dylan_health_runner.py` +- Write tests: + - `test_run_all_checks_all_pass()` - all checks return PASS + - `test_run_all_checks_with_failures()` - some checks return FAIL + - `test_run_all_checks_with_warnings()` - some checks return WARN + - `test_format_results_table()` - verify table formatting + - `test_format_results_json()` - verify JSON output format +- Mock individual check functions to return controlled results +- Mark all tests with `@pytest.mark.unit` +- All tests must have type hints + +### 10. Write Tests for CLI Interface + +- Create `dylan/utility_library/dylan_health/tests/test_dylan_health_cli.py` +- Import CliRunner from Typer for testing +- Write tests: + - `test_health_command_success()` - command exits with 0 when healthy + - `test_health_command_failure()` - command exits with 1 when unhealthy + - `test_health_command_verbose()` - verbose flag shows detailed output + - `test_health_command_json()` - json flag outputs valid JSON + - `test_health_command_integration()` - full command through main CLI app +- Mock runner functions to control output +- Mark all tests with `@pytest.mark.unit` +- All tests must have type hints + +### 11. Run Validation Commands + +Execute every command to validate the feature works correctly with zero regressions: + +- Run linter: `uv run ruff check dylan/` + - Fix any linting errors +- Run type checker: `uv run mypy dylan/` + - Fix any type errors +- Run unit tests: `uv run pytest dylan/utility_library/dylan_health/tests/ -m unit -v` + - Ensure all tests pass +- Run full test suite: `uv run pytest dylan/ -v` + - Ensure zero regressions in existing tests +- Test CLI command manually: `uv run dylan health` + - Verify output is formatted correctly + - Check exit codes work properly +- Test CLI command with flags: `uv run dylan health --verbose` + - Verify verbose output +- Test CLI command JSON output: `uv run dylan health --json` + - Verify valid JSON output +- Test CLI help: `uv run dylan health --help` + - Verify help text is clear and accurate +- Test main CLI integration: `uv run dylan` + - Verify health command appears in command list + +## Testing Strategy + +See `CLAUDE.md` for complete testing requirements. Every file in `dylan/utility_library/dylan_health/` must have a corresponding test file in `dylan/utility_library/dylan_health/tests/`. + +### Unit Tests + +All unit tests must: +- Be marked with `@pytest.mark.unit` +- Have complete type hints including return types +- Have Google-style docstrings +- Mock external dependencies (subprocess calls, file system, GitPython) +- Test both success and failure scenarios +- Test edge cases + +**Check Functions Tests** (`test_checks.py`): +- Test each individual health check function in isolation +- Mock subprocess calls to git, gh, claude commands +- Test success scenarios with expected output +- Test failure scenarios (command not found, errors) +- Test warning scenarios (e.g., gh installed but not authenticated) +- Verify HealthCheckResult objects have correct status and messages + +**Runner Logic Tests** (`test_dylan_health_runner.py`): +- Mock all individual check functions +- Test aggregation of results into HealthCheckReport +- Test overall status determination logic +- Test table formatting output +- Test JSON formatting output +- Verify proper type conversions + +**CLI Interface Tests** (`test_dylan_health_cli.py`): +- Use Typer's CliRunner for command testing +- Mock runner functions to control results +- Test exit codes (0 for healthy, 1 for unhealthy) +- Test --verbose flag behavior +- Test --json flag behavior +- Test command help text +- Test integration with main CLI app + +### Integration Tests + +If the feature interacts with multiple components, integration tests should: +- Be marked with `@pytest.mark.integration` +- Test actual command execution in a controlled environment +- Use real temporary git repositories (not mocked) +- Verify end-to-end flow from CLI to output +- Place in `dylan/utility_library/dylan_health/tests/test_integration.py` if needed + +**Potential Integration Tests**: +- `test_health_check_in_real_git_repo()` - Run health check in actual temporary git repo +- `test_health_check_with_missing_dependencies()` - Test behavior when dependencies unavailable +- `test_health_check_output_format()` - Verify Rich table renders correctly + +### Edge Cases + +Edge cases that need to be tested: +- Git repository in detached HEAD state +- Git repository with no commits yet +- Git repository with no remote configured +- Git repository in a submodule +- GitHub CLI installed but authentication expired +- Claude Code installed but different version than expected +- Running health check outside of a git repository +- Running health check with corrupted .git directory +- Subprocess commands timing out or hanging +- Subprocess commands returning unexpected output formats +- Missing pyproject.toml or other required files +- Symbolic links or unusual file system structures + +## Acceptance Criteria + +1. **Command Registration**: `dylan health` command is registered and appears in main help +2. **Dependency Checks**: All critical dependencies (git, gh, claude) are checked and reported +3. **Repository Checks**: Git repository state is validated (branch, clean/dirty, remote) +4. **Project Checks**: Expected project files are verified (pyproject.toml, etc.) +5. **Output Format**: Results displayed in clear, color-coded Rich table +6. **Exit Codes**: Command exits with 0 when healthy, 1 when unhealthy +7. **Verbose Mode**: `--verbose` flag shows detailed information +8. **JSON Output**: `--json` flag outputs valid, parseable JSON +9. **Error Handling**: Errors are caught and displayed with helpful messages +10. **Type Safety**: All code passes mypy strict type checking with zero errors +11. **Linting**: All code passes ruff linting with zero errors +12. **Test Coverage**: All functions have unit tests with >90% coverage +13. **Documentation**: Google-style docstrings on all public functions and classes +14. **Integration**: Command integrates seamlessly with existing Dylan CLI architecture +15. **UI Consistency**: Output styling matches Dylan's UI theme (colors, formatting) + +## Validation Commands + +Execute every command to validate the feature works correctly with zero regressions. + +**Required validation commands:** + +```bash +# Lint check must pass +uv run ruff check dylan/ + +# Type check must pass +uv run mypy dylan/ + +# Unit tests must pass +uv run pytest dylan/utility_library/dylan_health/tests/ -m unit -v + +# All tests must pass with zero regressions +uv run pytest dylan/ -v + +# Manual testing: Basic health check +uv run dylan health + +# Manual testing: Verbose output +uv run dylan health --verbose + +# Manual testing: JSON output +uv run dylan health --json + +# Manual testing: Command help +uv run dylan health --help + +# Manual testing: Main CLI integration +uv run dylan + +# Verify health command in help table +uv run dylan --help +``` + +**Expected Outcomes:** +- All linting and type checks pass with zero errors +- All tests pass with zero failures +- `dylan health` displays formatted table with check results +- `dylan health --verbose` shows additional details +- `dylan health --json` outputs valid JSON +- `dylan health --help` shows clear usage information +- `dylan` (main CLI) shows health command in commands table +- Exit code is 0 when all checks pass, 1 when any check fails + +## Notes + +### Implementation Considerations + +1. **Dependency Resilience**: Health checks should not fail catastrophically if a dependency is missing. Each check should gracefully handle errors and report them as failures. + +2. **Performance**: Health checks should execute quickly (< 3 seconds total). Use timeouts on subprocess calls to prevent hanging. + +3. **Extensibility**: The health check system should be designed to easily add new checks in the future. Consider a registry pattern if more than 10 checks are needed. + +4. **Security**: Be cautious with subprocess execution. Always use proper escaping and avoid shell=True. Use subprocess.run() with explicit arguments list. + +5. **Cross-Platform**: Ensure health checks work on macOS, Linux, and Windows. Note that some commands may have different output formats or locations across platforms. + +6. **Logging**: Use structured logging with correlation IDs for all operations. This helps with debugging when health checks fail. + +7. **Future Enhancements**: Consider adding: + - `--fix` flag to automatically attempt fixing common issues + - `--check ` to run specific checks only + - Caching of check results to speed up repeated runs + - Integration with monitoring/telemetry systems + +### Related Dylan Commands + +The health check command complements existing Dylan utilities: +- Run before `dylan review` to ensure environment is ready +- Run after installation to verify setup +- Run when troubleshooting issues with other commands +- Can be integrated into CI/CD pipelines as a validation step + +### Dependencies + +This feature uses only existing dependencies in `pyproject.toml`: +- `typer>=0.15.4` - CLI framework +- `rich>=14.0.0` - Terminal formatting +- `gitpython>=3.1.44` - Git repository operations + +No new dependencies need to be added. + +### Testing Infrastructure + +All tests use existing pytest infrastructure: +- Run with `uv run pytest` +- Use fixtures from `dylan/conftest.py` and module-specific conftest files +- Follow existing test patterns from other Dylan utilities +- Mark tests with `@pytest.mark.unit` for unit tests + +### Documentation Updates + +After implementation, update: +- `CLAUDE.md` - Add health command usage examples +- `README.md` - Add health command to feature list (if applicable) +- Consider adding troubleshooting section based on common health check failures diff --git a/dylan/cli.py b/dylan/cli.py index 694ef80..bb328e2 100644 --- a/dylan/cli.py +++ b/dylan/cli.py @@ -8,6 +8,7 @@ from rich.table import Table from .utility_library.dylan_dev.dylan_dev_cli import dev +from .utility_library.dylan_health.dylan_health_cli import health from .utility_library.dylan_pr.dylan_pr_cli import pr from .utility_library.dylan_release.dylan_release_cli import release_app from .utility_library.dylan_review.dylan_review_cli import review @@ -25,6 +26,7 @@ ) app.add_typer(standup_app, name="standup", help="Generate daily standup reports from git activity") app.add_typer(release_app, name="release", help="Create and manage project releases") +app.command(name="health", help="Check system health and dependencies")(health) app.command(name="review", help="Run AI-powered code reviews on git branches")(review) app.command(name="dev", help="Implement fixes from code reviews")(dev) app.command(name="pr", help="Create pull requests with AI-generated descriptions")(pr) @@ -56,6 +58,11 @@ def _main(ctx: typer.Context) -> None: "Generate daily standup reports", "dylan standup --since yesterday" ) + table.add_row( + "health", + "Check system health and dependencies", + "dylan health --verbose" + ) table.add_row( "review", "Run code reviews on branches", diff --git a/dylan/utility_library/dylan_health/__init__.py b/dylan/utility_library/dylan_health/__init__.py new file mode 100644 index 0000000..38c6129 --- /dev/null +++ b/dylan/utility_library/dylan_health/__init__.py @@ -0,0 +1,19 @@ +"""Health check module for Dylan CLI. + +This module provides comprehensive system diagnostics and health checks +for the Dylan development environment, verifying that all required +dependencies (Git, GitHub CLI, Claude Code) are properly installed and +configured. +""" + +from dylan.utility_library.dylan_health.checks import ( + HealthCheckResult, + HealthCheckStatus, +) +from dylan.utility_library.dylan_health.dylan_health_cli import health + +__all__ = [ + "health", + "HealthCheckResult", + "HealthCheckStatus", +] diff --git a/dylan/utility_library/dylan_health/checks.py b/dylan/utility_library/dylan_health/checks.py new file mode 100644 index 0000000..81efaa1 --- /dev/null +++ b/dylan/utility_library/dylan_health/checks.py @@ -0,0 +1,355 @@ +"""Individual health check functions for Dylan CLI. + +This module contains all individual health check functions that verify +the state of dependencies, repository configuration, and project structure. +Each check returns a standardized HealthCheckResult object. +""" + +import subprocess +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from git import GitError, InvalidGitRepositoryError, Repo + + +class HealthCheckStatus(Enum): + """Status of a health check result.""" + + PASS = "pass" # noqa: S105 - Not a password, just an enum value + FAIL = "fail" + WARN = "warn" + + +@dataclass +class HealthCheckResult: + """Result of an individual health check. + + Attributes: + name: Human-readable name of the check. + status: Status of the check (PASS, FAIL, or WARN). + message: Brief description of the result. + details: Optional additional information about the check result. + """ + + name: str + status: HealthCheckStatus + message: str + details: str | None = None + + +def check_git_installed() -> HealthCheckResult: + """Check if Git is installed and accessible. + + Returns: + HealthCheckResult with Git version if installed, error message otherwise. + """ + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + version = result.stdout.strip() + return HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.PASS, + message=f"Git is installed: {version}", + details=version, + ) + except FileNotFoundError: + return HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.FAIL, + message="Git is not installed or not in PATH", + details="Install Git from https://git-scm.com/", + ) + except subprocess.TimeoutExpired: + return HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.FAIL, + message="Git command timed out", + details="Git may be installed but not responding", + ) + except subprocess.CalledProcessError as e: + return HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.FAIL, + message="Git command failed", + details=f"Error: {e.stderr}", + ) + + +def check_git_repo() -> HealthCheckResult: + """Check if current directory is a Git repository. + + Returns: + HealthCheckResult with repository path if valid, error message otherwise. + """ + try: + repo = Repo(".", search_parent_directories=True) + repo_path = Path(repo.working_dir).resolve() + return HealthCheckResult( + name="Git Repository", + status=HealthCheckStatus.PASS, + message="Current directory is a Git repository", + details=str(repo_path), + ) + except InvalidGitRepositoryError: + return HealthCheckResult( + name="Git Repository", + status=HealthCheckStatus.FAIL, + message="Not a Git repository", + details="Run 'git init' to initialize a repository", + ) + except GitError as e: + return HealthCheckResult( + name="Git Repository", + status=HealthCheckStatus.FAIL, + message="Error accessing Git repository", + details=str(e), + ) + + +def check_git_branch() -> HealthCheckResult: + """Check current Git branch and working tree status. + + Returns: + HealthCheckResult with branch name and clean/dirty status. + """ + try: + repo = Repo(".", search_parent_directories=True) + + # Handle detached HEAD state + if repo.head.is_detached: + return HealthCheckResult( + name="Git Branch", + status=HealthCheckStatus.WARN, + message="Repository is in detached HEAD state", + details=f"Current commit: {repo.head.commit.hexsha[:7]}", + ) + + branch_name = repo.active_branch.name + is_dirty = repo.is_dirty() + + if is_dirty: + status_text = "has uncommitted changes" + details = f"Branch: {branch_name} (dirty)" + else: + status_text = "is clean" + details = f"Branch: {branch_name} (clean)" + + return HealthCheckResult( + name="Git Branch", + status=HealthCheckStatus.PASS, + message=f"On branch '{branch_name}' - working tree {status_text}", + details=details, + ) + except InvalidGitRepositoryError: + return HealthCheckResult( + name="Git Branch", + status=HealthCheckStatus.FAIL, + message="Not in a Git repository", + details="Cannot check branch status", + ) + except GitError as e: + return HealthCheckResult( + name="Git Branch", + status=HealthCheckStatus.FAIL, + message="Error checking branch status", + details=str(e), + ) + + +def check_git_remote() -> HealthCheckResult: + """Check if Git remote 'origin' is configured. + + Returns: + HealthCheckResult with remote URL if configured, warning otherwise. + """ + try: + repo = Repo(".", search_parent_directories=True) + + if "origin" not in repo.remotes: + return HealthCheckResult( + name="Git Remote", + status=HealthCheckStatus.WARN, + message="No 'origin' remote configured", + details="Configure with: git remote add origin ", + ) + + origin = repo.remotes.origin + remote_url = list(origin.urls)[0] if origin.urls else "No URL" + + return HealthCheckResult( + name="Git Remote", + status=HealthCheckStatus.PASS, + message="Remote 'origin' is configured", + details=remote_url, + ) + except InvalidGitRepositoryError: + return HealthCheckResult( + name="Git Remote", + status=HealthCheckStatus.FAIL, + message="Not in a Git repository", + details="Cannot check remote configuration", + ) + except GitError as e: + return HealthCheckResult( + name="Git Remote", + status=HealthCheckStatus.FAIL, + message="Error checking remote configuration", + details=str(e), + ) + + +def check_gh_cli_installed() -> HealthCheckResult: + """Check if GitHub CLI is installed and authenticated. + + Returns: + HealthCheckResult indicating installation and authentication status. + """ + # First check if gh is installed + try: + result = subprocess.run( + ["gh", "--version"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + version = result.stdout.strip().split("\n")[0] + except FileNotFoundError: + return HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.FAIL, + message="GitHub CLI is not installed", + details="Install from https://cli.github.com/", + ) + except subprocess.TimeoutExpired: + return HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.FAIL, + message="GitHub CLI command timed out", + details="gh may be installed but not responding", + ) + except subprocess.CalledProcessError as e: + return HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.FAIL, + message="GitHub CLI command failed", + details=f"Error: {e.stderr}", + ) + + # Check authentication status + try: + subprocess.run( + ["gh", "auth", "status"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + return HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.PASS, + message=f"{version} - Authenticated", + details="Authentication successful", + ) + except subprocess.CalledProcessError: + return HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.WARN, + message=f"{version} - Not authenticated", + details="Run 'gh auth login' to authenticate", + ) + except subprocess.TimeoutExpired: + return HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.WARN, + message=f"{version} - Auth check timed out", + details="Could not verify authentication status", + ) + + +def check_claude_code_installed() -> HealthCheckResult: + """Check if Claude Code CLI is installed. + + Returns: + HealthCheckResult with Claude version if installed, error message otherwise. + """ + try: + result = subprocess.run( + ["claude", "--version"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + version = result.stdout.strip() + return HealthCheckResult( + name="Claude Code CLI", + status=HealthCheckStatus.PASS, + message=f"Claude Code is installed: {version}", + details=version, + ) + except FileNotFoundError: + return HealthCheckResult( + name="Claude Code CLI", + status=HealthCheckStatus.FAIL, + message="Claude Code CLI is not installed", + details="Install from https://claude.ai/code", + ) + except subprocess.TimeoutExpired: + return HealthCheckResult( + name="Claude Code CLI", + status=HealthCheckStatus.FAIL, + message="Claude command timed out", + details="Claude may be installed but not responding", + ) + except subprocess.CalledProcessError as e: + return HealthCheckResult( + name="Claude Code CLI", + status=HealthCheckStatus.FAIL, + message="Claude command failed", + details=f"Error: {e.stderr}", + ) + + +def check_project_structure() -> HealthCheckResult: + """Check if expected project files exist. + + Returns: + HealthCheckResult indicating which required files are present or missing. + """ + required_files = ["pyproject.toml", "README.md", ".git"] + missing_files = [] + + for file_name in required_files: + if not Path(file_name).exists(): + missing_files.append(file_name) + + if not missing_files: + return HealthCheckResult( + name="Project Structure", + status=HealthCheckStatus.PASS, + message="All required project files exist", + details=f"Found: {', '.join(required_files)}", + ) + + if len(missing_files) == len(required_files): + return HealthCheckResult( + name="Project Structure", + status=HealthCheckStatus.FAIL, + message="No project files found", + details=f"Missing: {', '.join(missing_files)}", + ) + + return HealthCheckResult( + name="Project Structure", + status=HealthCheckStatus.WARN, + message="Some project files are missing", + details=f"Missing: {', '.join(missing_files)}", + ) diff --git a/dylan/utility_library/dylan_health/dylan_health_cli.py b/dylan/utility_library/dylan_health/dylan_health_cli.py new file mode 100644 index 0000000..bcf83dd --- /dev/null +++ b/dylan/utility_library/dylan_health/dylan_health_cli.py @@ -0,0 +1,84 @@ +"""CLI interface for Dylan health check command. + +This module defines the Typer command interface for running health checks +from the command line. +""" + +import sys + +import typer +from rich.console import Console + +from dylan.utility_library.dylan_health.dylan_health_runner import ( + format_results_json, + format_results_table, + run_all_checks, +) +from dylan.utility_library.shared.error_handling import handle_dylan_errors +from dylan.utility_library.shared.ui_theme import COLORS + +console = Console() + + +@handle_dylan_errors(utility_name="health") +def health( + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show detailed output including additional information for each check", + ), + json_output: bool = typer.Option( + False, + "--json", + help="Output results in JSON format for scripting and automation", + ), +) -> None: + """Check system health and dependencies. + + Performs comprehensive diagnostics on your Dylan development environment, + verifying that all required dependencies (Git, GitHub CLI, Claude Code) + are properly installed and configured. + + Args: + verbose: If True, displays detailed output with extra information. + json_output: If True, outputs results in JSON format instead of table. + + Returns: + None. Exits with code 0 if healthy, 1 if unhealthy. + """ + # Run all health checks + report = run_all_checks() + + # Output results based on format + if json_output: + # JSON output mode + json_str = format_results_json(report) + console.print(json_str) + else: + # Table output mode + console.print() # Empty line for spacing + table = format_results_table(report) + console.print(table) + + # Show detailed info in verbose mode + if verbose: + console.print(f"\n[bold {COLORS['primary']}]Detailed Information:[/]") + for result in report.results: + if result.details: + console.print(f" [{COLORS['muted']}]• {result.name}:[/] {result.details}") + + # Display overall summary + console.print() + if report.overall_healthy: + console.print( + f"[{COLORS['success']}]✓ Environment is healthy![/] All checks passed or have minor warnings." + ) + else: + console.print( + f"[{COLORS['error']}]✗ Environment has issues.[/] Please address the failed checks above." + ) + console.print() + + # Exit with appropriate code + sys.exit(0 if report.overall_healthy else 1) diff --git a/dylan/utility_library/dylan_health/dylan_health_runner.py b/dylan/utility_library/dylan_health/dylan_health_runner.py new file mode 100644 index 0000000..e6089cf --- /dev/null +++ b/dylan/utility_library/dylan_health/dylan_health_runner.py @@ -0,0 +1,120 @@ +"""Core runner logic for Dylan health checks. + +This module orchestrates the execution of all health checks and formats +the results for display. +""" + +import json +from dataclasses import dataclass +from typing import Any + +from rich.table import Table + +from dylan.utility_library.dylan_health.checks import ( + HealthCheckResult, + HealthCheckStatus, + check_claude_code_installed, + check_gh_cli_installed, + check_git_branch, + check_git_installed, + check_git_remote, + check_git_repo, + check_project_structure, +) +from dylan.utility_library.shared.ui_theme import COLORS + + +@dataclass +class HealthCheckReport: + """Aggregated health check results. + + Attributes: + results: List of individual health check results. + overall_healthy: True if all checks passed or have warnings only. + """ + + results: list[HealthCheckResult] + overall_healthy: bool + + +def run_all_checks() -> HealthCheckReport: + """Execute all health checks and aggregate results. + + Returns: + HealthCheckReport containing all check results and overall status. + """ + results: list[HealthCheckResult] = [] + + # Run all checks in sequence + results.append(check_git_installed()) + results.append(check_git_repo()) + results.append(check_git_branch()) + results.append(check_git_remote()) + results.append(check_gh_cli_installed()) + results.append(check_claude_code_installed()) + results.append(check_project_structure()) + + # Determine overall health - healthy if no FAIL status + overall_healthy = all( + result.status != HealthCheckStatus.FAIL for result in results + ) + + return HealthCheckReport(results=results, overall_healthy=overall_healthy) + + +def format_results_table(report: HealthCheckReport) -> Table: + """Format health check results as a Rich table. + + Args: + report: The health check report to format. + + Returns: + Rich Table object with formatted results. + """ + table = Table( + title="Dylan Health Check Results", + show_header=True, + header_style=f"bold {COLORS['primary']}", + ) + + table.add_column("Check", style="bold", no_wrap=True) + table.add_column("Status", justify="center", no_wrap=True) + table.add_column("Message", style="dim") + + for result in report.results: + # Determine status display and color + if result.status == HealthCheckStatus.PASS: + status_display = f"[{COLORS['success']}]✓ PASS[/]" + elif result.status == HealthCheckStatus.WARN: + status_display = f"[{COLORS['warning']}]⚠ WARN[/]" + else: # FAIL + status_display = f"[{COLORS['error']}]✗ FAIL[/]" + + table.add_row(result.name, status_display, result.message) + + return table + + +def format_results_json(report: HealthCheckReport) -> str: + """Format health check results as JSON. + + Args: + report: The health check report to format. + + Returns: + JSON string representation of the report. + """ + output: dict[str, Any] = { + "overall_healthy": report.overall_healthy, + "checks": [ + { + "name": result.name, + "status": result.status.value, + "message": result.message, + "details": result.details, + } + for result in report.results + ], + } + + return json.dumps(output, indent=2) diff --git a/dylan/utility_library/dylan_health/tests/__init__.py b/dylan/utility_library/dylan_health/tests/__init__.py new file mode 100644 index 0000000..435b8da --- /dev/null +++ b/dylan/utility_library/dylan_health/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for dylan_health module.""" diff --git a/dylan/utility_library/dylan_health/tests/conftest.py b/dylan/utility_library/dylan_health/tests/conftest.py new file mode 100644 index 0000000..c78cc26 --- /dev/null +++ b/dylan/utility_library/dylan_health/tests/conftest.py @@ -0,0 +1,150 @@ +"""Pytest fixtures for the dylan_health module.""" + +import subprocess +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from git import Repo + + +@pytest.fixture +def mock_subprocess_run(monkeypatch: pytest.MonkeyPatch) -> Callable[[dict[str, Any]], None]: + """Mock subprocess.run for testing external commands. + + Returns: + A function that configures the mock behavior based on command. + """ + + def _configure_mock(command_responses: dict[str, Any]) -> None: + """Configure mock subprocess.run responses. + + Args: + command_responses: Dictionary mapping command strings to response dicts. + Each response dict can contain: stdout, stderr, returncode, exception. + """ + + def mock_run(cmd: list[str], *args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: + cmd_key = " ".join(cmd) + response = command_responses.get(cmd_key, {}) + + if "exception" in response: + raise response["exception"] + + return subprocess.CompletedProcess( + args=cmd, + returncode=response.get("returncode", 0), + stdout=response.get("stdout", ""), + stderr=response.get("stderr", ""), + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + return _configure_mock + + +@pytest.fixture +def temp_git_repo() -> Any: + """Create a temporary git repository for testing. + + Returns: + Path to the temporary repository directory. + + Yields: + Path object pointing to the temporary git repository. + """ + with tempfile.TemporaryDirectory() as tmpdir: + repo_path = Path(tmpdir) + repo = Repo.init(repo_path) + + # Create initial commit + test_file = repo_path / "test.txt" + test_file.write_text("test content") + repo.index.add(["test.txt"]) + repo.index.commit("Initial commit") + + # Add remote + repo.create_remote("origin", "https://github.com/test/repo.git") + + yield repo_path + + +@pytest.fixture +def mock_git_repo(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Mock GitPython Repo for testing. + + Returns: + MagicMock configured to behave like a git repository. + """ + mock_repo = MagicMock() + mock_repo.working_dir = "/fake/repo/path" + mock_repo.is_dirty.return_value = False + mock_repo.head.is_detached = False + mock_repo.active_branch.name = "main" + + # Mock remotes as an object with both dict-like and attribute access + mock_remote = MagicMock() + mock_remote.urls = ["https://github.com/test/repo.git"] + mock_remotes = MagicMock() + mock_remotes.__contains__ = lambda self, key: key == "origin" + mock_remotes.origin = mock_remote + mock_repo.remotes = mock_remotes + + def mock_repo_init(*args: Any, **kwargs: Any) -> MagicMock: + return mock_repo + + monkeypatch.setattr("dylan.utility_library.dylan_health.checks.Repo", mock_repo_init) + + return mock_repo + + +@pytest.fixture +def successful_git_version() -> dict[str, Any]: + """Response for successful git --version command. + + Returns: + Dict with stdout containing git version string. + """ + return { + "git --version": { + "stdout": "git version 2.39.2", + "returncode": 0, + } + } + + +@pytest.fixture +def successful_gh_version() -> dict[str, Any]: + """Response for successful gh --version command. + + Returns: + Dict with stdout containing gh version string. + """ + return { + "gh --version": { + "stdout": "gh version 2.40.0 (2023-12-15)\n", + "returncode": 0, + }, + "gh auth status": { + "stdout": "Logged in to github.com as testuser\n", + "returncode": 0, + }, + } + + +@pytest.fixture +def successful_claude_version() -> dict[str, Any]: + """Response for successful claude --version command. + + Returns: + Dict with stdout containing claude version string. + """ + return { + "claude --version": { + "stdout": "Claude Code CLI v1.0.0", + "returncode": 0, + } + } diff --git a/dylan/utility_library/dylan_health/tests/test_checks.py b/dylan/utility_library/dylan_health/tests/test_checks.py new file mode 100644 index 0000000..e67af12 --- /dev/null +++ b/dylan/utility_library/dylan_health/tests/test_checks.py @@ -0,0 +1,474 @@ +"""Unit tests for individual health check functions.""" + +import subprocess +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from git import GitError, InvalidGitRepositoryError + +from dylan.utility_library.dylan_health.checks import ( + HealthCheckStatus, + check_claude_code_installed, + check_gh_cli_installed, + check_git_branch, + check_git_installed, + check_git_remote, + check_git_repo, + check_project_structure, +) + + +@pytest.mark.unit +def test_check_git_installed_success( + mock_subprocess_run: Any, successful_git_version: dict[str, Any] +) -> None: + """Test successful git installation check.""" + mock_subprocess_run(successful_git_version) + + result = check_git_installed() + + assert result.status == HealthCheckStatus.PASS + assert "git version 2.39.2" in result.message + assert result.name == "Git Installation" + assert result.details is not None + + +@pytest.mark.unit +def test_check_git_installed_failure(mock_subprocess_run: Any) -> None: + """Test git installation check when git is not found.""" + mock_subprocess_run( + { + "git --version": { + "exception": FileNotFoundError("git not found"), + } + } + ) + + result = check_git_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "not installed" in result.message.lower() + assert result.details is not None + assert "https://git-scm.com/" in result.details + + +@pytest.mark.unit +def test_check_git_installed_timeout(mock_subprocess_run: Any) -> None: + """Test git installation check when command times out.""" + mock_subprocess_run( + { + "git --version": { + "exception": subprocess.TimeoutExpired(cmd="git --version", timeout=5), + } + } + ) + + result = check_git_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "timed out" in result.message.lower() + + +@pytest.mark.unit +def test_check_git_repo_success(mock_git_repo: MagicMock) -> None: + """Test successful git repository check.""" + result = check_git_repo() + + assert result.status == HealthCheckStatus.PASS + assert "Git repository" in result.message + assert result.details is not None + + +@pytest.mark.unit +def test_check_git_repo_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """Test git repository check when not in a git repo.""" + + def mock_repo_init(*args: Any, **kwargs: Any) -> None: + raise InvalidGitRepositoryError("Not a git repository") + + monkeypatch.setattr("dylan.utility_library.dylan_health.checks.Repo", mock_repo_init) + + result = check_git_repo() + + assert result.status == HealthCheckStatus.FAIL + assert "Not a Git repository" in result.message + + +@pytest.mark.unit +def test_check_git_branch_clean(mock_git_repo: MagicMock) -> None: + """Test git branch check with clean working tree.""" + mock_git_repo.is_dirty.return_value = False + + result = check_git_branch() + + assert result.status == HealthCheckStatus.PASS + assert "main" in result.message + assert "clean" in result.message.lower() + + +@pytest.mark.unit +def test_check_git_branch_dirty(mock_git_repo: MagicMock) -> None: + """Test git branch check with uncommitted changes.""" + mock_git_repo.is_dirty.return_value = True + + result = check_git_branch() + + assert result.status == HealthCheckStatus.PASS + assert "main" in result.message + assert "uncommitted changes" in result.message.lower() + + +@pytest.mark.unit +def test_check_git_branch_detached_head(mock_git_repo: MagicMock) -> None: + """Test git branch check in detached HEAD state.""" + mock_git_repo.head.is_detached = True + mock_git_repo.head.commit.hexsha = "abc123def456789" + + result = check_git_branch() + + assert result.status == HealthCheckStatus.WARN + assert "detached HEAD" in result.message + + +@pytest.mark.unit +def test_check_git_branch_not_in_repo(monkeypatch: pytest.MonkeyPatch) -> None: + """Test git branch check when not in a repository.""" + + def mock_repo_init(*args: Any, **kwargs: Any) -> None: + raise InvalidGitRepositoryError("Not a git repository") + + monkeypatch.setattr("dylan.utility_library.dylan_health.checks.Repo", mock_repo_init) + + result = check_git_branch() + + assert result.status == HealthCheckStatus.FAIL + assert "Not in a Git repository" in result.message + + +@pytest.mark.unit +def test_check_git_remote_exists(mock_git_repo: MagicMock) -> None: + """Test git remote check when origin is configured.""" + result = check_git_remote() + + assert result.status == HealthCheckStatus.PASS + assert "origin" in result.message.lower() + assert result.details is not None + assert "https://github.com/test/repo.git" in result.details + + +@pytest.mark.unit +def test_check_git_remote_missing(mock_git_repo: MagicMock) -> None: + """Test git remote check when no remote is configured.""" + mock_remotes = MagicMock() + mock_remotes.__contains__ = lambda self, key: False + mock_git_repo.remotes = mock_remotes + + result = check_git_remote() + + assert result.status == HealthCheckStatus.WARN + assert "No 'origin' remote" in result.message + + +@pytest.mark.unit +def test_check_git_remote_not_in_repo(monkeypatch: pytest.MonkeyPatch) -> None: + """Test git remote check when not in a repository.""" + + def mock_repo_init(*args: Any, **kwargs: Any) -> None: + raise InvalidGitRepositoryError("Not a git repository") + + monkeypatch.setattr("dylan.utility_library.dylan_health.checks.Repo", mock_repo_init) + + result = check_git_remote() + + assert result.status == HealthCheckStatus.FAIL + + +@pytest.mark.unit +def test_check_gh_cli_installed_and_authenticated( + mock_subprocess_run: Any, successful_gh_version: dict[str, Any] +) -> None: + """Test gh CLI check when installed and authenticated.""" + mock_subprocess_run(successful_gh_version) + + result = check_gh_cli_installed() + + assert result.status == HealthCheckStatus.PASS + assert "Authenticated" in result.message + assert "gh version" in result.message + + +@pytest.mark.unit +def test_check_gh_cli_installed_not_authenticated(mock_subprocess_run: Any) -> None: + """Test gh CLI check when installed but not authenticated.""" + mock_subprocess_run( + { + "gh --version": { + "stdout": "gh version 2.40.0 (2023-12-15)\n", + "returncode": 0, + }, + "gh auth status": { + "exception": subprocess.CalledProcessError( + returncode=1, cmd="gh auth status", stderr="Not authenticated" + ), + }, + } + ) + + result = check_gh_cli_installed() + + assert result.status == HealthCheckStatus.WARN + assert "Not authenticated" in result.message + assert result.details is not None + assert "gh auth login" in result.details + + +@pytest.mark.unit +def test_check_gh_cli_not_installed(mock_subprocess_run: Any) -> None: + """Test gh CLI check when not installed.""" + mock_subprocess_run( + { + "gh --version": { + "exception": FileNotFoundError("gh not found"), + } + } + ) + + result = check_gh_cli_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "not installed" in result.message.lower() + assert result.details is not None + assert "https://cli.github.com/" in result.details + + +@pytest.mark.unit +def test_check_claude_code_installed_success( + mock_subprocess_run: Any, successful_claude_version: dict[str, Any] +) -> None: + """Test successful Claude Code installation check.""" + mock_subprocess_run(successful_claude_version) + + result = check_claude_code_installed() + + assert result.status == HealthCheckStatus.PASS + assert "Claude Code" in result.message + assert "v1.0.0" in result.message + + +@pytest.mark.unit +def test_check_claude_code_not_installed(mock_subprocess_run: Any) -> None: + """Test Claude Code check when not installed.""" + mock_subprocess_run( + { + "claude --version": { + "exception": FileNotFoundError("claude not found"), + } + } + ) + + result = check_claude_code_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "not installed" in result.message.lower() + assert result.details is not None + assert "https://claude.ai/code" in result.details + + +@pytest.mark.unit +def test_check_project_structure_complete(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test project structure check when all files exist.""" + monkeypatch.chdir(tmp_path) + + # Create required files + (tmp_path / "pyproject.toml").touch() + (tmp_path / "README.md").touch() + (tmp_path / ".git").mkdir() + + result = check_project_structure() + + assert result.status == HealthCheckStatus.PASS + assert "All required" in result.message + assert result.details is not None + assert "pyproject.toml" in result.details + + +@pytest.mark.unit +def test_check_project_structure_missing_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test project structure check when some files are missing.""" + monkeypatch.chdir(tmp_path) + + # Only create some files + (tmp_path / "pyproject.toml").touch() + (tmp_path / ".git").mkdir() + # README.md is missing + + result = check_project_structure() + + assert result.status == HealthCheckStatus.WARN + assert "Some project files are missing" in result.message + assert "README.md" in result.details + + +@pytest.mark.unit +def test_check_project_structure_no_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test project structure check when no files exist.""" + monkeypatch.chdir(tmp_path) + + result = check_project_structure() + + assert result.status == HealthCheckStatus.FAIL + assert "No project files found" in result.message + + +@pytest.mark.unit +def test_check_git_repo_git_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Test git repository check when GitError is raised.""" + + def mock_repo_init(*args: Any, **kwargs: Any) -> None: + raise GitError("Git command failed") + + monkeypatch.setattr("dylan.utility_library.dylan_health.checks.Repo", mock_repo_init) + + result = check_git_repo() + + assert result.status == HealthCheckStatus.FAIL + assert "Error accessing" in result.message + + +@pytest.mark.unit +def test_check_git_branch_git_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Test git branch check when GitError is raised.""" + + def mock_repo_init(*args: Any, **kwargs: Any) -> MagicMock: + mock_repo = MagicMock() + mock_repo.head.is_detached = False + # Make active_branch raise GitError when accessed + type(mock_repo).active_branch = property(lambda self: (_ for _ in ()).throw(GitError("Git error"))) + return mock_repo + + monkeypatch.setattr("dylan.utility_library.dylan_health.checks.Repo", mock_repo_init) + + result = check_git_branch() + + assert result.status == HealthCheckStatus.FAIL + assert "Error checking branch" in result.message + + +@pytest.mark.unit +def test_check_git_remote_git_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Test git remote check when GitError is raised.""" + + def mock_repo_init(*args: Any, **kwargs: Any) -> MagicMock: + mock_repo = MagicMock() + # Make remotes raise GitError when accessed with 'in' operator + mock_remotes = MagicMock() + mock_remotes.__contains__ = MagicMock(side_effect=GitError("Git error")) + mock_repo.remotes = mock_remotes + return mock_repo + + monkeypatch.setattr("dylan.utility_library.dylan_health.checks.Repo", mock_repo_init) + + result = check_git_remote() + + assert result.status == HealthCheckStatus.FAIL + assert "Error checking remote" in result.message + + +@pytest.mark.unit +def test_check_gh_cli_timeout(mock_subprocess_run: Any) -> None: + """Test gh CLI check when authentication check times out.""" + mock_subprocess_run( + { + "gh --version": { + "stdout": "gh version 2.40.0\n", + "returncode": 0, + }, + "gh auth status": { + "exception": subprocess.TimeoutExpired(cmd="gh auth status", timeout=5), + }, + } + ) + + result = check_gh_cli_installed() + + assert result.status == HealthCheckStatus.WARN + assert "timed out" in result.message.lower() + + +@pytest.mark.unit +def test_check_claude_code_timeout(mock_subprocess_run: Any) -> None: + """Test Claude Code check when command times out.""" + mock_subprocess_run( + { + "claude --version": { + "exception": subprocess.TimeoutExpired(cmd="claude --version", timeout=5), + } + } + ) + + result = check_claude_code_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "timed out" in result.message.lower() + + +@pytest.mark.unit +def test_check_git_installed_called_process_error(mock_subprocess_run: Any) -> None: + """Test git installation check when command returns non-zero exit code.""" + mock_subprocess_run( + { + "git --version": { + "stdout": "", + "stderr": "git: command failed", + "returncode": 1, + "exception": subprocess.CalledProcessError( + returncode=1, cmd="git --version", stderr="git: command failed" + ), + } + } + ) + + result = check_git_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "command failed" in result.message.lower() + + +@pytest.mark.unit +def test_check_gh_cli_version_error(mock_subprocess_run: Any) -> None: + """Test gh CLI check when version command fails.""" + mock_subprocess_run( + { + "gh --version": { + "exception": subprocess.CalledProcessError( + returncode=1, cmd="gh --version", stderr="gh: command failed" + ), + } + } + ) + + result = check_gh_cli_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "command failed" in result.message.lower() + + +@pytest.mark.unit +def test_check_claude_code_called_process_error(mock_subprocess_run: Any) -> None: + """Test Claude Code check when command fails.""" + mock_subprocess_run( + { + "claude --version": { + "exception": subprocess.CalledProcessError( + returncode=1, cmd="claude --version", stderr="claude: command failed" + ), + } + } + ) + + result = check_claude_code_installed() + + assert result.status == HealthCheckStatus.FAIL + assert "command failed" in result.message.lower() diff --git a/dylan/utility_library/dylan_health/tests/test_dylan_health_cli.py b/dylan/utility_library/dylan_health/tests/test_dylan_health_cli.py new file mode 100644 index 0000000..8d8d075 --- /dev/null +++ b/dylan/utility_library/dylan_health/tests/test_dylan_health_cli.py @@ -0,0 +1,360 @@ +"""Unit tests for health check CLI interface.""" + +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from dylan.cli import app +from dylan.utility_library.dylan_health.checks import HealthCheckResult, HealthCheckStatus +from dylan.utility_library.dylan_health.dylan_health_runner import HealthCheckReport + +runner = CliRunner() + + +@pytest.mark.unit +def test_health_command_success() -> None: + """Test health command exits with 0 when environment is healthy.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.PASS, + message="Git is installed", + ), + HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.PASS, + message="Authenticated", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health"]) + + assert result.exit_code == 0 + assert "healthy" in result.stdout.lower() + + +@pytest.mark.unit +def test_health_command_failure() -> None: + """Test health command exits with 1 when environment has issues.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.FAIL, + message="Git is not installed", + ), + HealthCheckResult( + name="GitHub CLI", + status=HealthCheckStatus.PASS, + message="Authenticated", + ), + ], + overall_healthy=False, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health"]) + + assert result.exit_code == 1 + assert "issues" in result.stdout.lower() + + +@pytest.mark.unit +def test_health_command_verbose() -> None: + """Test health command with --verbose flag shows detailed output.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.PASS, + message="Git is installed", + details="git version 2.39.2", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health", "--verbose"]) + + assert result.exit_code == 0 + assert "Detailed Information" in result.stdout + assert "git version 2.39.2" in result.stdout + + +@pytest.mark.unit +def test_health_command_json() -> None: + """Test health command with --json flag outputs valid JSON.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.PASS, + message="Git is installed", + details="git version 2.39.2", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health", "--json"]) + + assert result.exit_code == 0 + # Verify JSON-like structure in output + assert "overall_healthy" in result.stdout + assert "checks" in result.stdout + assert "Git Installation" in result.stdout + + +@pytest.mark.unit +def test_health_command_verbose_short_flag() -> None: + """Test health command with -v short flag.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Test Check", + status=HealthCheckStatus.PASS, + message="OK", + details="Extra details", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health", "-v"]) + + assert result.exit_code == 0 + assert "Detailed Information" in result.stdout + assert "Extra details" in result.stdout + + +@pytest.mark.unit +def test_health_command_with_warnings() -> None: + """Test health command with warnings still exits 0.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Git Branch", + status=HealthCheckStatus.WARN, + message="Detached HEAD state", + ), + HealthCheckResult( + name="Git Installation", + status=HealthCheckStatus.PASS, + message="Installed", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health"]) + + assert result.exit_code == 0 + assert "healthy" in result.stdout.lower() + + +@pytest.mark.unit +def test_health_command_verbose_without_details() -> None: + """Test verbose mode when some checks have no details.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Check With Details", + status=HealthCheckStatus.PASS, + message="OK", + details="Some details", + ), + HealthCheckResult( + name="Check Without Details", + status=HealthCheckStatus.PASS, + message="OK", + details=None, + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health", "--verbose"]) + + assert result.exit_code == 0 + assert "Some details" in result.stdout + # Check that we only show details for checks that have them + + +@pytest.mark.unit +def test_health_command_help() -> None: + """Test health command --help shows usage information.""" + result = runner.invoke(app, ["health", "--help"]) + + assert result.exit_code == 0 + assert "Check system health and dependencies" in result.stdout + assert "--verbose" in result.stdout + assert "--json" in result.stdout + + +@pytest.mark.unit +def test_health_command_integration() -> None: + """Test full health command through main CLI app.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Test Check", + status=HealthCheckStatus.PASS, + message="All good", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + + +@pytest.mark.unit +def test_health_command_mixed_status() -> None: + """Test health command with mixed check statuses.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Pass Check", + status=HealthCheckStatus.PASS, + message="OK", + ), + HealthCheckResult( + name="Warn Check", + status=HealthCheckStatus.WARN, + message="Warning", + ), + HealthCheckResult( + name="Fail Check", + status=HealthCheckStatus.FAIL, + message="Error", + ), + ], + overall_healthy=False, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health"]) + + assert result.exit_code == 1 + # Should contain table with all status types + assert "PASS" in result.stdout or "✓" in result.stdout + assert "WARN" in result.stdout or "⚠" in result.stdout + assert "FAIL" in result.stdout or "✗" in result.stdout + + +@pytest.mark.unit +def test_health_command_json_with_failure() -> None: + """Test JSON output when environment is unhealthy.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Failed Check", + status=HealthCheckStatus.FAIL, + message="Error occurred", + details="Error details", + ), + ], + overall_healthy=False, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = mock_report + + result = runner.invoke(app, ["health", "--json"]) + + assert result.exit_code == 1 + assert '"overall_healthy": false' in result.stdout.lower() + assert "Failed Check" in result.stdout + + +@pytest.mark.unit +def test_health_command_runs_all_checks() -> None: + """Test that health command actually runs all checks.""" + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run: + mock_run.return_value = HealthCheckReport(results=[], overall_healthy=True) + + runner.invoke(app, ["health"]) + + # Verify run_all_checks was called + mock_run.assert_called_once() + + +@pytest.mark.unit +def test_health_command_table_format() -> None: + """Test that default output uses table format.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Test", + status=HealthCheckStatus.PASS, + message="OK", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run, \ + patch("dylan.utility_library.dylan_health.dylan_health_cli.format_results_table") as mock_table: + mock_run.return_value = mock_report + + runner.invoke(app, ["health"]) + + # Verify table formatting was called + mock_table.assert_called_once_with(mock_report) + + +@pytest.mark.unit +def test_health_command_json_format() -> None: + """Test that --json flag uses JSON formatter.""" + mock_report = HealthCheckReport( + results=[ + HealthCheckResult( + name="Test", + status=HealthCheckStatus.PASS, + message="OK", + ), + ], + overall_healthy=True, + ) + + with patch("dylan.utility_library.dylan_health.dylan_health_cli.run_all_checks") as mock_run, \ + patch("dylan.utility_library.dylan_health.dylan_health_cli.format_results_json") as mock_json: + mock_run.return_value = mock_report + mock_json.return_value = '{"test": "json"}' + + runner.invoke(app, ["health", "--json"]) + + # Verify JSON formatting was called + mock_json.assert_called_once_with(mock_report) diff --git a/dylan/utility_library/dylan_health/tests/test_dylan_health_runner.py b/dylan/utility_library/dylan_health/tests/test_dylan_health_runner.py new file mode 100644 index 0000000..2b4561d --- /dev/null +++ b/dylan/utility_library/dylan_health/tests/test_dylan_health_runner.py @@ -0,0 +1,279 @@ +"""Unit tests for health check runner logic.""" + +import json +from unittest.mock import patch + +import pytest +from rich.table import Table + +from dylan.utility_library.dylan_health.checks import HealthCheckResult, HealthCheckStatus +from dylan.utility_library.dylan_health.dylan_health_runner import ( + HealthCheckReport, + format_results_json, + format_results_table, + run_all_checks, +) + + +@pytest.mark.unit +def test_run_all_checks_all_pass() -> None: + """Test run_all_checks when all checks return PASS.""" + with patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_installed") as mock_git, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_repo") as mock_repo, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_branch") as mock_branch, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_remote") as mock_remote, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_gh_cli_installed") as mock_gh, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_claude_code_installed") as mock_claude, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_project_structure") as mock_structure: + + # Configure all mocks to return PASS + mock_git.return_value = HealthCheckResult( + name="Git Installation", status=HealthCheckStatus.PASS, message="Git installed" + ) + mock_repo.return_value = HealthCheckResult( + name="Git Repository", status=HealthCheckStatus.PASS, message="Valid repo" + ) + mock_branch.return_value = HealthCheckResult( + name="Git Branch", status=HealthCheckStatus.PASS, message="On main" + ) + mock_remote.return_value = HealthCheckResult( + name="Git Remote", status=HealthCheckStatus.PASS, message="Remote configured" + ) + mock_gh.return_value = HealthCheckResult( + name="GitHub CLI", status=HealthCheckStatus.PASS, message="Authenticated" + ) + mock_claude.return_value = HealthCheckResult( + name="Claude Code", status=HealthCheckStatus.PASS, message="Installed" + ) + mock_structure.return_value = HealthCheckResult( + name="Project Structure", status=HealthCheckStatus.PASS, message="All files present" + ) + + report = run_all_checks() + + assert len(report.results) == 7 + assert report.overall_healthy is True + assert all(r.status == HealthCheckStatus.PASS for r in report.results) + + +@pytest.mark.unit +def test_run_all_checks_with_failures() -> None: + """Test run_all_checks when some checks return FAIL.""" + with patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_installed") as mock_git, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_repo") as mock_repo, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_branch") as mock_branch, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_remote") as mock_remote, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_gh_cli_installed") as mock_gh, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_claude_code_installed") as mock_claude, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_project_structure") as mock_structure: + + # Mix of PASS and FAIL + mock_git.return_value = HealthCheckResult( + name="Git Installation", status=HealthCheckStatus.FAIL, message="Not installed" + ) + mock_repo.return_value = HealthCheckResult( + name="Git Repository", status=HealthCheckStatus.PASS, message="Valid repo" + ) + mock_branch.return_value = HealthCheckResult( + name="Git Branch", status=HealthCheckStatus.PASS, message="On main" + ) + mock_remote.return_value = HealthCheckResult( + name="Git Remote", status=HealthCheckStatus.PASS, message="Remote configured" + ) + mock_gh.return_value = HealthCheckResult( + name="GitHub CLI", status=HealthCheckStatus.FAIL, message="Not installed" + ) + mock_claude.return_value = HealthCheckResult( + name="Claude Code", status=HealthCheckStatus.PASS, message="Installed" + ) + mock_structure.return_value = HealthCheckResult( + name="Project Structure", status=HealthCheckStatus.PASS, message="All files present" + ) + + report = run_all_checks() + + assert len(report.results) == 7 + assert report.overall_healthy is False + assert sum(1 for r in report.results if r.status == HealthCheckStatus.FAIL) == 2 + + +@pytest.mark.unit +def test_run_all_checks_with_warnings() -> None: + """Test run_all_checks when some checks return WARN.""" + with patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_installed") as mock_git, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_repo") as mock_repo, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_branch") as mock_branch, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_remote") as mock_remote, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_gh_cli_installed") as mock_gh, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_claude_code_installed") as mock_claude, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_project_structure") as mock_structure: + + # All PASS except one WARN + mock_git.return_value = HealthCheckResult( + name="Git Installation", status=HealthCheckStatus.PASS, message="Installed" + ) + mock_repo.return_value = HealthCheckResult( + name="Git Repository", status=HealthCheckStatus.PASS, message="Valid repo" + ) + mock_branch.return_value = HealthCheckResult( + name="Git Branch", status=HealthCheckStatus.WARN, message="Detached HEAD" + ) + mock_remote.return_value = HealthCheckResult( + name="Git Remote", status=HealthCheckStatus.PASS, message="Remote configured" + ) + mock_gh.return_value = HealthCheckResult( + name="GitHub CLI", status=HealthCheckStatus.WARN, message="Not authenticated" + ) + mock_claude.return_value = HealthCheckResult( + name="Claude Code", status=HealthCheckStatus.PASS, message="Installed" + ) + mock_structure.return_value = HealthCheckResult( + name="Project Structure", status=HealthCheckStatus.PASS, message="All files present" + ) + + report = run_all_checks() + + assert len(report.results) == 7 + # Warnings don't make it unhealthy, only failures do + assert report.overall_healthy is True + assert sum(1 for r in report.results if r.status == HealthCheckStatus.WARN) == 2 + + +@pytest.mark.unit +def test_format_results_table() -> None: + """Test format_results_table creates a valid Rich table.""" + results = [ + HealthCheckResult( + name="Test Check 1", status=HealthCheckStatus.PASS, message="Success" + ), + HealthCheckResult( + name="Test Check 2", status=HealthCheckStatus.WARN, message="Warning" + ), + HealthCheckResult( + name="Test Check 3", status=HealthCheckStatus.FAIL, message="Failed" + ), + ] + report = HealthCheckReport(results=results, overall_healthy=False) + + table = format_results_table(report) + + assert isinstance(table, Table) + assert table.title == "Dylan Health Check Results" + assert len(table.columns) == 3 + # Check column names + assert table.columns[0].header == "Check" + assert table.columns[1].header == "Status" + assert table.columns[2].header == "Message" + + +@pytest.mark.unit +def test_format_results_json() -> None: + """Test format_results_json produces valid JSON.""" + results = [ + HealthCheckResult( + name="Test Check 1", + status=HealthCheckStatus.PASS, + message="Success", + details="Extra info", + ), + HealthCheckResult( + name="Test Check 2", + status=HealthCheckStatus.FAIL, + message="Failed", + details=None, + ), + ] + report = HealthCheckReport(results=results, overall_healthy=False) + + json_str = format_results_json(report) + + # Parse JSON to verify it's valid + data = json.loads(json_str) + + assert data["overall_healthy"] is False + assert len(data["checks"]) == 2 + assert data["checks"][0]["name"] == "Test Check 1" + assert data["checks"][0]["status"] == "pass" + assert data["checks"][0]["message"] == "Success" + assert data["checks"][0]["details"] == "Extra info" + assert data["checks"][1]["status"] == "fail" + assert data["checks"][1]["details"] is None + + +@pytest.mark.unit +def test_format_results_json_empty_report() -> None: + """Test format_results_json with empty results.""" + report = HealthCheckReport(results=[], overall_healthy=True) + + json_str = format_results_json(report) + data = json.loads(json_str) + + assert data["overall_healthy"] is True + assert data["checks"] == [] + + +@pytest.mark.unit +def test_health_check_report_dataclass() -> None: + """Test HealthCheckReport dataclass attributes.""" + results = [ + HealthCheckResult( + name="Test", status=HealthCheckStatus.PASS, message="Test message" + ) + ] + report = HealthCheckReport(results=results, overall_healthy=True) + + assert len(report.results) == 1 + assert report.overall_healthy is True + assert report.results[0].name == "Test" + + +@pytest.mark.unit +def test_run_all_checks_calls_all_functions() -> None: + """Test that run_all_checks calls all individual check functions.""" + with patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_installed") as mock_git, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_repo") as mock_repo, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_branch") as mock_branch, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_git_remote") as mock_remote, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_gh_cli_installed") as mock_gh, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_claude_code_installed") as mock_claude, \ + patch("dylan.utility_library.dylan_health.dylan_health_runner.check_project_structure") as mock_structure: + + # Set return values + for mock in [mock_git, mock_repo, mock_branch, mock_remote, mock_gh, mock_claude, mock_structure]: + mock.return_value = HealthCheckResult( + name="Test", status=HealthCheckStatus.PASS, message="OK" + ) + + run_all_checks() + + # Verify all functions were called exactly once + mock_git.assert_called_once() + mock_repo.assert_called_once() + mock_branch.assert_called_once() + mock_remote.assert_called_once() + mock_gh.assert_called_once() + mock_claude.assert_called_once() + mock_structure.assert_called_once() + + +@pytest.mark.unit +def test_format_results_table_with_all_status_types() -> None: + """Test format_results_table handles all status types correctly.""" + results = [ + HealthCheckResult( + name="Pass Check", status=HealthCheckStatus.PASS, message="All good" + ), + HealthCheckResult( + name="Warn Check", status=HealthCheckStatus.WARN, message="Be careful" + ), + HealthCheckResult( + name="Fail Check", status=HealthCheckStatus.FAIL, message="Error occurred" + ), + ] + report = HealthCheckReport(results=results, overall_healthy=False) + + table = format_results_table(report) + + # Verify table was created successfully with all status types + assert isinstance(table, Table) + assert len(table.rows) == 3 diff --git a/pyproject.toml b/pyproject.toml index a84d27a..d983097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,10 @@ addopts = [ "--strict-markers", ] norecursedirs = ["**/provider_clis/[!tests]*"] +markers = [ + "unit: Unit tests", + "integration: Integration tests", +] [tool.coverage.run] source = ["dylan"] diff --git a/uv.lock b/uv.lock index 28c25dd..14a569b 100644 --- a/uv.lock +++ b/uv.lock @@ -212,7 +212,7 @@ wheels = [ [[package]] name = "dylan" -version = "0.6.10" +version = "0.6.11" source = { editable = "." } dependencies = [ { name = "filelock" },