diff --git a/codeframe/enforcement/README.md b/codeframe/enforcement/README.md deleted file mode 100644 index dc7b4d77..00000000 --- a/codeframe/enforcement/README.md +++ /dev/null @@ -1,287 +0,0 @@ -# Agent Quality Enforcement - Dual-Layer Architecture - -## Overview - -This module provides **language-agnostic quality enforcement** for AI agents working on ANY codebase. It complements the Python-specific tools in `scripts/` used for codeframe development. - -## Dual-Layer Design - -### Layer 1: Python-Specific (for codeframe repo) -**Location**: `scripts/`, `.pre-commit-config.yaml` - -Tools for enforcing quality on codeframe Python development: -- `scripts/verify-ai-claims.sh` - pytest/coverage verification -- `scripts/detect-skip-abuse.py` - Python AST-based skip detection -- `scripts/quality-ratchet.py` - pytest JSON report parsing -- `.pre-commit-config.yaml` - Python pre-commit hooks - -### Layer 2: Language-Agnostic (for agent enforcement) -**Location**: `codeframe/enforcement/` - -Tools for agents working on ANY language: -- `LanguageDetector` - Detects project language/framework -- `AdaptiveTestRunner` - Runs tests for any language -- `SkipPatternDetector` - Finds skip patterns across languages (TODO) -- `QualityTracker` - Generic quality metrics (TODO) -- `EvidenceVerifier` - Validates agent claims (TODO) - -## Supported Languages - -| Language | Framework | Test Command | Coverage | Skip Patterns | -|----------|-----------|--------------|----------|---------------| -| Python | pytest | `pytest -v` | `--cov` | `@skip`, `@pytest.mark.skip` | -| Python | unittest | `python -m unittest` | `coverage run` | `@unittest.skip` | -| JavaScript | Jest | `npm test` | `--coverage` | `it.skip`, `test.skip` | -| TypeScript | Jest/Vitest | `npm test` | `--coverage` | `it.skip`, `describe.skip` | -| Go | go test | `go test ./...` | `-cover` | `t.Skip()`, `// +build ignore` | -| Rust | cargo | `cargo test` | `tarpaulin` | `#[ignore]` | -| Java | Maven | `mvn test` | `jacoco` | `@Ignore`, `@Disabled` | -| Java | Gradle | `./gradlew test` | `jacocoTestReport` | `@Ignore`, `@Disabled` | -| Ruby | RSpec | `bundle exec rspec` | built-in | `skip`, `pending`, `xit` | -| C# | .NET | `dotnet test` | `/p:CollectCoverage=true` | `[Ignore]`, `[Skip]` | - -## Usage by WorkerAgent - -```python -from codeframe.enforcement import ( - LanguageDetector, - AdaptiveTestRunner, - EvidenceVerifier -) - -class WorkerAgent: - async def verify_work(self, project_path: str): - """Verify agent's work regardless of language.""" - - # Detect language - detector = LanguageDetector(project_path) - lang_info = detector.detect() - - print(f"Detected: {lang_info.language} ({lang_info.framework})") - - # Run tests - runner = AdaptiveTestRunner(project_path) - result = await runner.run_tests(with_coverage=True) - - if result.success: - print(f"✓ {result.passed_tests}/{result.total_tests} tests passed") - print(f"✓ Coverage: {result.coverage}%") - else: - print(f"✗ {result.failed_tests} tests failed") - raise QualityError("Tests failing") - - # Verify evidence - verifier = EvidenceVerifier() - evidence = verifier.collect(result) - - return evidence -``` - -## Agent Behavior Rules (Language-Agnostic) - -Regardless of language, agents must: - -1. **Test-First Development** - - Write failing test FIRST - - Implement code to pass test - - Provide test output as evidence - -2. **No Skip Abuse** - - Never skip tests without strong justification - - Patterns vary by language but principle is universal - -3. **Quality Thresholds** - - Maintain coverage ≥85% (configurable) - - All tests must pass before claiming done - - No degradation from peak quality - -4. **Evidence Required** - - Full test output - - Coverage report - - Skip violation check results - -## Configuration - -Each project can override defaults in `.codeframe/enforcement.json`: - -```json -{ - "language": "auto", - "coverage_threshold": 85, - "allow_skips": false, - "quality_tracking": true, - "test_command": null, - "custom_skip_patterns": [] -} -``` - -## Implementation Status - -✅ **Completed:** -- LanguageDetector (9 languages supported) -- AdaptiveTestRunner (multi-language test execution) -- SkipPatternDetector (multi-language skip detection) -- QualityTracker (generic quality metrics) -- EvidenceVerifier (claim validation) ✨ **NEW** -- WorkerAgent integration ✨ **NEW** -- Configuration system (environment variables) ✨ **NEW** -- Database evidence storage (audit trail) ✨ **NEW** -- Python-specific tools (scripts/) - -📋 **Planned:** -- Integration tests for evidence workflow -- Additional language support (PHP, Swift, Kotlin) -- Dashboard integration for evidence visualization - -## WorkerAgent Integration - -The EvidenceVerifier is automatically integrated into the WorkerAgent's task completion workflow: - -```python -# In WorkerAgent.complete_task() -# 1. Quality gates run and produce results -quality_result = await quality_gates.run_all_gates(task) - -# 2. Evidence extracted from quality gate results -test_result = quality_gates.get_test_results_from_gate_result(quality_result) -skip_violations = quality_gates.get_skip_violations_from_gate_result(quality_result) - -# 3. Evidence collected and verified -verifier = EvidenceVerifier(**get_evidence_config()) -evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=skip_violations, - language=lang_info.language, - agent_id=self.agent_id, - task_description=task.title, - framework=lang_info.framework, -) - -# 4. Verification enforces requirements -is_valid = verifier.verify(evidence) - -# 5. If invalid, create blocker with detailed report -if not is_valid: - report = verifier.generate_report(evidence) - blocker_id = self._create_evidence_blocker(task, evidence, report) - # Evidence stored for audit trail - self.db.task_repository.save_task_evidence(task.id, evidence) - return {"success": False, "status": "blocked"} - -# 6. If valid, store evidence and complete task -evidence_id = self.db.task_repository.save_task_evidence(task.id, evidence) -# Mark task as completed... -``` - -**Configuration (via environment variables):** -- `CODEFRAME_REQUIRE_COVERAGE=true` - Whether coverage is required -- `CODEFRAME_MIN_COVERAGE=85.0` - Minimum coverage percentage -- `CODEFRAME_ALLOW_SKIPPED_TESTS=false` - Whether skipped tests are allowed -- `CODEFRAME_MIN_PASS_RATE=100.0` - Minimum test pass rate - -**Database Storage:** -Evidence records are stored in the `task_evidence` table with full audit trail including: -- Test results (passed, failed, skipped counts) -- Coverage percentage -- Skip violations (with file, line, pattern, context) -- Quality metrics -- Verification status and errors -- Timestamps for historical tracking - -**Database Migration:** - -For **new installations**, the `task_evidence` table is created automatically on first run. - -For **existing deployments**, you need to add the `task_evidence` table manually: - -```sql --- Add evidence storage table -CREATE TABLE IF NOT EXISTS task_evidence ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - agent_id TEXT NOT NULL, - language TEXT NOT NULL, - framework TEXT, - - -- Test results - total_tests INTEGER NOT NULL, - passed_tests INTEGER NOT NULL, - failed_tests INTEGER NOT NULL, - skipped_tests INTEGER NOT NULL, - pass_rate REAL NOT NULL, - coverage REAL, - test_output TEXT NOT NULL, - - -- Skip violations - skip_violations_count INTEGER NOT NULL DEFAULT 0, - skip_violations_json TEXT, - skip_check_passed BOOLEAN NOT NULL, - - -- Quality metrics - quality_metrics_json TEXT NOT NULL, - - -- Verification status - verified BOOLEAN NOT NULL, - verification_errors TEXT, - - -- Metadata - timestamp TEXT NOT NULL, - task_description TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Add indexes for performance -CREATE INDEX IF NOT EXISTS idx_task_evidence_task ON task_evidence(task_id); -CREATE INDEX IF NOT EXISTS idx_task_evidence_verified ON task_evidence(verified, created_at DESC); -``` - -**Verification:** -Run this query to verify the table exists: -```sql -SELECT name FROM sqlite_master WHERE type='table' AND name='task_evidence'; -``` - -If the table doesn't exist when evidence collection runs, you'll see an error like: -``` -sqlite3.OperationalError: no such table: task_evidence -``` - -## Architecture Decisions - -### Why Dual-Layer? - -1. **Codeframe Development**: Python-specific tools are useful for this repo -2. **Agent Flexibility**: Agents need language-agnostic enforcement -3. **No Duplication**: Each layer serves distinct purpose -4. **Evolution**: Layer 2 can expand without affecting Layer 1 - -### Detection Strategy - -Language detection uses multiple signals: -- Config files (package.json, Cargo.toml, etc.) - highest confidence -- File extensions (.py, .js, .rs) - medium confidence -- Directory structure (tests/, __tests__/) - lower confidence - -### Test Output Parsing - -Each language has unique output format: -- Python: "5 passed, 2 failed in 1.23s" -- JavaScript/Jest: "Tests: 2 failed, 8 passed, 10 total" -- Go: "PASS/FAIL:" prefix lines -- Rust: "test result: ok. 10 passed; 0 failed" - -The adaptive parser handles all formats. - -## Future Enhancements - -1. **More Languages**: PHP, Swift, Kotlin, Scala, Elixir -2. **Custom Parsers**: Plugin system for custom test frameworks -3. **Quality Dashboards**: Real-time quality metrics across projects -4. **AI Guidance**: Suggestions when quality degrades -5. **Multi-Project**: Track quality across agent's entire portfolio - -## See Also - -- Python-specific enforcement: `scripts/README.md` -- Agent documentation: `docs/AGENTS.md` -- TDD workflow: `.claude/rules.md` diff --git a/codeframe/enforcement/__init__.py b/codeframe/enforcement/__init__.py deleted file mode 100644 index 39ef42c8..00000000 --- a/codeframe/enforcement/__init__.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Agent Quality Enforcement - Language-Agnostic Layer - -This module provides quality enforcement for AI agents working on ANY codebase, -regardless of language or framework. - -Architecture: - ┌─────────────────────────────────────────────────────┐ - │ WorkerAgent │ - │ ├── Uses LanguageDetector to identify project │ - │ ├── Uses AdaptiveTestRunner to run tests │ - │ ├── Uses SkipPatternDetector for skip abuse │ - │ ├── Uses QualityTracker for metrics │ - │ └── Uses EvidenceVerifier before claiming done │ - └─────────────────────────────────────────────────────┘ - -The dual-layer approach: -1. Layer 1 (Python-specific): Tools in scripts/ for codeframe development -2. Layer 2 (Language-agnostic): This module for agent enforcement on ANY project -""" - -""" -Agent Quality Enforcement - Complete API - -All modules for language-agnostic quality enforcement: -- LanguageDetector: Detect language and framework -- AdaptiveTestRunner: Run tests for any language -- SkipPatternDetector: Find skip patterns across languages -- QualityTracker: Track quality metrics generically -- EvidenceVerifier: Validate agent claims - -Example usage: - from codeframe.enforcement import ( - LanguageDetector, - AdaptiveTestRunner, - SkipPatternDetector, - QualityTracker, - EvidenceVerifier, - ) - - # Detect language - detector = LanguageDetector("/path/to/project") - lang_info = detector.detect() - - # Run tests - runner = AdaptiveTestRunner("/path/to/project") - test_result = await runner.run_tests(with_coverage=True) - - # Check for skip abuse - skip_detector = SkipPatternDetector("/path/to/project") - violations = skip_detector.detect_all() - - # Track quality - tracker = QualityTracker("/path/to/project") - tracker.record(quality_metrics) - degradation = tracker.check_degradation() - - # Verify evidence - verifier = EvidenceVerifier() - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=violations, - language=lang_info.language, - agent_id="worker-001", - task="Implement feature X" - ) - is_valid = verifier.verify(evidence) -""" - -from .language_detector import LanguageDetector, LanguageInfo # noqa: E402 -from .adaptive_test_runner import AdaptiveTestRunner, TestResult # noqa: E402 -from .skip_pattern_detector import SkipPatternDetector, SkipViolation # noqa: E402 -from .quality_tracker import QualityTracker, QualityMetrics # noqa: E402 -from .evidence_verifier import EvidenceVerifier, Evidence # noqa: E402 - -__all__ = [ - "LanguageDetector", - "LanguageInfo", - "AdaptiveTestRunner", - "TestResult", - "SkipPatternDetector", - "SkipViolation", - "QualityTracker", - "QualityMetrics", - "EvidenceVerifier", - "Evidence", -] - -__version__ = "0.1.0" diff --git a/codeframe/enforcement/adaptive_test_runner.py b/codeframe/enforcement/adaptive_test_runner.py deleted file mode 100644 index 42dbd401..00000000 --- a/codeframe/enforcement/adaptive_test_runner.py +++ /dev/null @@ -1,412 +0,0 @@ -""" -Adaptive Test Runner - -Runs tests for any language/framework by detecting the project type -and using appropriate commands. - -This is what agents use to verify their work, regardless of what -language they're working on. -""" - -import subprocess -import shlex -import logging -from dataclasses import dataclass -from typing import Optional, Dict, Any, List, Union -from pathlib import Path - -from .language_detector import LanguageDetector, LanguageInfo - -logger = logging.getLogger(__name__) - -# Safe commands that can run without shell=True -# These are common test commands and package managers that don't require shell features -SAFE_COMMANDS = { - # Python - "pytest", - "python", - "python3", - "uv", # UV package manager - "poetry", - "pipenv", - "pip", - "pip3", - # JavaScript/TypeScript - "npm", - "node", - "yarn", - "pnpm", - "bun", - "deno", - # Go - "go", - # Rust - "cargo", - # Java - "mvn", - "gradle", - "java", - # Ruby - "ruby", - "rspec", - "bundle", - "rake", - # .NET - "dotnet", - # PHP - "composer", - "phpunit", -} - - -@dataclass -class TestResult: - """Results from running tests.""" - - __test__ = False # Not a test class - it's a data model for test results - - success: bool # True if all tests passed - total_tests: int # Total number of tests - passed_tests: int # Number of passed tests - failed_tests: int # Number of failed tests - skipped_tests: int # Number of skipped tests - pass_rate: float # Percentage of tests that passed (0-100) - coverage: Optional[float] # Coverage percentage if available - output: str # Full test output - duration: float # Test duration in seconds - - -class AdaptiveTestRunner: - """ - Runs tests adaptively based on detected language. - - Usage: - runner = AdaptiveTestRunner(project_path="/path/to/project") - result = await runner.run_tests() - - if result.success: - print(f"✓ {result.passed_tests} tests passed") - else: - print(f"✗ {result.failed_tests} tests failed") - """ - - def __init__(self, project_path: str = "."): - self.project_path = Path(project_path) - self.detector = LanguageDetector(project_path) - self.language_info: Optional[LanguageInfo] = None - - def _parse_command_safely(self, command: str) -> tuple[Union[str, List[str]], bool]: - """ - Parse command and determine if shell=True is needed. - - Args: - command: Command string to parse - - Returns: - Tuple of (parsed_command, use_shell) - - parsed_command: List of args for shell=False, or str for shell=True - - use_shell: Boolean indicating if shell=True is needed - - Security: - - Commands starting with SAFE_COMMANDS are parsed with shlex.split() - and run with shell=False (secure) - - Commands containing shell operators require shell=True (less secure, - logged as warning) - - Simple commands without operators use shell=False when possible - """ - # Check for dangerous shell operators - dangerous_operators = [";", "&&", "||", "|", "`", "$(", "$()", ">", "<", ">>"] - has_shell_operators = any(op in command for op in dangerous_operators) - - # Parse command to get the base command - try: - parts = shlex.split(command) - except ValueError as e: - logger.warning( - f"Failed to parse command safely: {command}. " - f"Error: {e}. Using shell=True as fallback." - ) - return command, True - - if not parts: - logger.warning(f"Empty command after parsing: {command}") - return command, True - - base_command = parts[0] - - # If command contains shell operators, we need shell=True - if has_shell_operators: - logger.warning( - f"Command contains shell operators and will run with shell=True: {command}. " - f"This may pose a security risk if the command comes from untrusted input." - ) - return command, True - - # If base command is in SAFE_COMMANDS, use shell=False - if base_command in SAFE_COMMANDS: - logger.debug(f"Running safe command without shell: {parts}") - return parts, False - - # For other simple commands, try without shell - logger.info( - f"Command '{base_command}' not in SAFE_COMMANDS list. " - f"Running without shell, but consider adding to SAFE_COMMANDS if legitimate." - ) - return parts, False - - async def run_tests(self, with_coverage: bool = False) -> TestResult: - """ - Run tests for the project. - - Args: - with_coverage: Whether to collect coverage data - - Returns: - TestResult with test execution details - """ - # Detect language if not already done - if not self.language_info: - self.language_info = self.detector.detect() - - # Choose command - command = ( - self.language_info.coverage_command - if with_coverage and self.language_info.coverage_command - else self.language_info.test_command - ) - - # Parse command safely - parsed_command, use_shell = self._parse_command_safely(command) - - # Run tests with appropriate shell setting - result = subprocess.run( - parsed_command, - shell=use_shell, - cwd=self.project_path, - capture_output=True, - text=True, - timeout=300, # 5 minute timeout - ) - - # Parse output based on language - parsed = self._parse_output( - result.stdout + result.stderr, - self.language_info.language, - self.language_info.framework, - ) - - return TestResult( - success=result.returncode == 0, - total_tests=parsed["total"], - passed_tests=parsed["passed"], - failed_tests=parsed["failed"], - skipped_tests=parsed["skipped"], - pass_rate=parsed["pass_rate"], - coverage=parsed.get("coverage"), - output=result.stdout + result.stderr, - duration=0.0, # Would need timing logic - ) - - def _parse_output(self, output: str, language: str, framework: Optional[str]) -> Dict[str, Any]: - """ - Parse test output to extract metrics. - - Args: - output: Raw test output - language: Detected language - framework: Detected framework - - Returns: - Dict with total, passed, failed, skipped, pass_rate, coverage - """ - # Default values - result = { - "total": 0, - "passed": 0, - "failed": 0, - "skipped": 0, - "pass_rate": 0.0, - "coverage": None, - } - - # Language-specific parsing - if language == "python" and framework == "pytest": - result.update(self._parse_pytest(output)) - elif language in ["javascript", "typescript"] and framework == "jest": - result.update(self._parse_jest(output)) - elif language == "go": - result.update(self._parse_go_test(output)) - elif language == "rust": - result.update(self._parse_cargo_test(output)) - elif language == "java": - result.update(self._parse_java_test(output)) - else: - # Generic parsing - look for common patterns - result.update(self._parse_generic(output)) - - return result - - def _parse_pytest(self, output: str) -> Dict[str, Any]: - """Parse pytest output.""" - import re - - result = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} - - # Look for summary line like "5 passed, 2 failed, 1 skipped in 1.23s" - summary_match = re.search(r"(\d+)\s+passed|(\d+)\s+failed|(\d+)\s+skipped", output) - - if summary_match: - # Extract numbers - passed_match = re.search(r"(\d+)\s+passed", output) - failed_match = re.search(r"(\d+)\s+failed", output) - skipped_match = re.search(r"(\d+)\s+skipped", output) - - result["passed"] = int(passed_match.group(1)) if passed_match else 0 - result["failed"] = int(failed_match.group(1)) if failed_match else 0 - result["skipped"] = int(skipped_match.group(1)) if skipped_match else 0 - result["total"] = result["passed"] + result["failed"] + result["skipped"] - - # Look for coverage in output - cov_match = re.search(r"TOTAL.*?(\d+)%", output) - if cov_match: - result["coverage"] = float(cov_match.group(1)) - - # Calculate pass rate - if result["total"] > 0: - result["pass_rate"] = (result["passed"] / result["total"]) * 100 - - return result - - def _parse_jest(self, output: str) -> Dict[str, Any]: - """Parse Jest output.""" - import re - - result = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} - - # Jest summary: "Tests: 2 failed, 8 passed, 10 total" - tests_match = re.search(r"Tests:\s+.*?(\d+)\s+total", output) - passed_match = re.search(r"(\d+)\s+passed", output) - failed_match = re.search(r"(\d+)\s+failed", output) - - if tests_match: - result["total"] = int(tests_match.group(1)) - if passed_match: - result["passed"] = int(passed_match.group(1)) - if failed_match: - result["failed"] = int(failed_match.group(1)) - - # Coverage - cov_match = re.search(r"All files\s+\|\s+(\d+\.?\d*)", output) - if cov_match: - result["coverage"] = float(cov_match.group(1)) - - if result["total"] > 0: - result["pass_rate"] = (result["passed"] / result["total"]) * 100 - - return result - - def _parse_go_test(self, output: str) -> Dict[str, Any]: - """Parse go test output.""" - import re - - result = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} - - # Count PASS and FAIL lines - passed = len(re.findall(r"^PASS:", output, re.MULTILINE)) - failed = len(re.findall(r"^FAIL:", output, re.MULTILINE)) - - result["passed"] = passed - result["failed"] = failed - result["total"] = passed + failed - - # Coverage: "coverage: 85.2% of statements" - cov_match = re.search(r"coverage:\s+(\d+\.?\d*)%", output) - if cov_match: - result["coverage"] = float(cov_match.group(1)) - - if result["total"] > 0: - result["pass_rate"] = (result["passed"] / result["total"]) * 100 - - return result - - def _parse_cargo_test(self, output: str) -> Dict[str, Any]: - """Parse cargo test output.""" - import re - - result = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} - - # Cargo: "test result: ok. 10 passed; 0 failed; 0 ignored" - match = re.search( - r"test result:.*?(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored", - output, - ) - - if match: - result["passed"] = int(match.group(1)) - result["failed"] = int(match.group(2)) - result["skipped"] = int(match.group(3)) - result["total"] = result["passed"] + result["failed"] + result["skipped"] - - if result["total"] > 0: - result["pass_rate"] = (result["passed"] / result["total"]) * 100 - - return result - - def _parse_java_test(self, output: str) -> Dict[str, Any]: - """Parse JUnit/Maven/Gradle test output.""" - import re - - result = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} - - # Maven/Gradle: "Tests run: 10, Failures: 0, Errors: 0, Skipped: 1" - match = re.search( - r"Tests run:\s+(\d+),\s+Failures:\s+(\d+),\s+Errors:\s+(\d+),\s+Skipped:\s+(\d+)", - output, - ) - - if match: - total = int(match.group(1)) - failures = int(match.group(2)) - errors = int(match.group(3)) - skipped = int(match.group(4)) - - result["total"] = total - result["failed"] = failures + errors - result["skipped"] = skipped - result["passed"] = total - result["failed"] - result["skipped"] - - if result["total"] > 0: - result["pass_rate"] = (result["passed"] / result["total"]) * 100 - - return result - - def _parse_generic(self, output: str) -> Dict[str, Any]: - """Generic parsing for unknown frameworks.""" - import re - - result = {"total": 0, "passed": 0, "failed": 0, "skipped": 0} - - # Look for common patterns - # Try to find numbers that might be test counts - lines = output.split("\n") - - for line in lines: - # Look for summary-like lines - if "passed" in line.lower() and "failed" in line.lower(): - numbers = re.findall(r"\d+", line) - if len(numbers) >= 2: - result["passed"] = int(numbers[0]) - result["failed"] = int(numbers[1]) - result["total"] = result["passed"] + result["failed"] - break - - if result["total"] > 0: - result["pass_rate"] = (result["passed"] / result["total"]) * 100 - - return result - - def get_language_info(self) -> Optional[LanguageInfo]: - """Get the detected language information.""" - if not self.language_info: - self.language_info = self.detector.detect() - return self.language_info diff --git a/codeframe/enforcement/evidence_verifier.py b/codeframe/enforcement/evidence_verifier.py deleted file mode 100644 index 825ee56b..00000000 --- a/codeframe/enforcement/evidence_verifier.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Evidence Verifier - -Validates that AI agents provide proper evidence before claiming tasks are complete. -Works with ANY language - adapts to the project being worked on. - -Evidence required: -1. Test execution output -2. Coverage report (if applicable) -3. Skip pattern check results -4. Quality metrics - -This prevents agents from claiming "tests pass" without proof. -""" - -from dataclasses import dataclass -from typing import Optional, List, Dict -from datetime import datetime - -from .adaptive_test_runner import TestResult -from .skip_pattern_detector import SkipViolation -from .quality_tracker import QualityMetrics - - -@dataclass -class Evidence: - """ - Complete evidence package from an AI agent. - - This is what agents must provide before claiming a task is complete. - """ - - # Test results - test_result: TestResult - test_output: str # Full test output for verification - - # Skip pattern check - skip_violations: List[SkipViolation] - skip_check_passed: bool - - # Quality metrics - quality_metrics: QualityMetrics - - # Metadata - timestamp: str - language: str - framework: Optional[str] - agent_id: str - task_description: str - - # Verification status - verified: bool = False - verification_errors: List[str] = None - - -class EvidenceVerifier: - """ - Verifies evidence provided by AI agents. - - Usage: - verifier = EvidenceVerifier() - - # Collect evidence - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=skip_violations, - language="python", - agent_id="worker-001", - task="Implement user authentication" - ) - - # Verify evidence - is_valid = verifier.verify(evidence) - - if is_valid: - print("✓ Evidence validated - task complete") - else: - print("✗ Evidence insufficient:") - for error in evidence.verification_errors: - print(f" - {error}") - """ - - def __init__( - self, - require_coverage: bool = True, - min_coverage: float = 85.0, - allow_skipped_tests: bool = False, - min_pass_rate: float = 100.0, - ): - """ - Initialize verifier with requirements. - - Args: - require_coverage: Whether coverage is required - min_coverage: Minimum coverage percentage (default: 85%) - allow_skipped_tests: Whether skipped tests are allowed - min_pass_rate: Minimum test pass rate (default: 100%) - """ - self.require_coverage = require_coverage - self.min_coverage = min_coverage - self.allow_skipped_tests = allow_skipped_tests - self.min_pass_rate = min_pass_rate - - def collect_evidence( - self, - test_result: TestResult, - skip_violations: List[SkipViolation], - language: str, - agent_id: str, - task_description: str, - framework: Optional[str] = None, - ) -> Evidence: - """ - Collect evidence from various sources into a single package. - - Args: - test_result: Results from running tests - skip_violations: List of skip pattern violations - language: Programming language - agent_id: Identifier for the agent - task_description: Description of task being completed - framework: Test framework (optional) - - Returns: - Evidence object - """ - # Create quality metrics from test result - quality_metrics = QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=0, # Will be set by tracker - test_pass_rate=test_result.pass_rate, - coverage_percentage=test_result.coverage or 0.0, - total_tests=test_result.total_tests, - passed_tests=test_result.passed_tests, - failed_tests=test_result.failed_tests, - language=language, - framework=framework, - ) - - evidence = Evidence( - test_result=test_result, - test_output=test_result.output, - skip_violations=skip_violations, - skip_check_passed=len(skip_violations) == 0, - quality_metrics=quality_metrics, - timestamp=datetime.now().isoformat(), - language=language, - framework=framework, - agent_id=agent_id, - task_description=task_description, - verification_errors=[], - ) - - return evidence - - def verify(self, evidence: Evidence) -> bool: - """ - Verify that evidence meets requirements. - - Args: - evidence: Evidence to verify - - Returns: - True if evidence is valid, False otherwise - """ - errors = [] - - # Check 1: Tests must pass - if not evidence.test_result.success: - errors.append(f"Tests failed: {evidence.test_result.failed_tests} failures") - - # Check 2: Pass rate must meet threshold - if evidence.test_result.pass_rate < self.min_pass_rate: - errors.append( - f"Pass rate too low: {evidence.test_result.pass_rate:.1f}% " - f"(minimum: {self.min_pass_rate:.1f}%)" - ) - - # Check 3: Coverage must meet threshold (if required) - if self.require_coverage: - coverage = evidence.test_result.coverage - if coverage is None: - errors.append("Coverage data missing (required)") - elif coverage < self.min_coverage: - errors.append( - f"Coverage too low: {coverage:.1f}% " f"(minimum: {self.min_coverage:.1f}%)" - ) - - # Check 4: No skip violations (unless allowed) - if not self.allow_skipped_tests and not evidence.skip_check_passed: - errors.append(f"Skip violations detected: {len(evidence.skip_violations)} violations") - - # Check 5: Must have test output - if not evidence.test_output or len(evidence.test_output) < 10: - errors.append("Test output missing or too short") - - # Check 6: No skipped tests in test results - if not self.allow_skipped_tests and evidence.test_result.skipped_tests > 0: - errors.append( - f"Skipped tests detected: {evidence.test_result.skipped_tests} tests skipped" - ) - - # Update evidence - evidence.verification_errors = errors - evidence.verified = len(errors) == 0 - - return evidence.verified - - def generate_report(self, evidence: Evidence) -> str: - """ - Generate a human-readable verification report. - - Args: - evidence: Evidence to report on - - Returns: - Formatted report string - """ - report_lines = [ - "=" * 70, - " EVIDENCE VERIFICATION REPORT", - "=" * 70, - "", - f"Agent ID: {evidence.agent_id}", - f"Task: {evidence.task_description}", - f"Language: {evidence.language}", - f"Framework: {evidence.framework or 'N/A'}", - f"Timestamp: {evidence.timestamp}", - "", - "Test Results:", - f" • Total tests: {evidence.test_result.total_tests}", - f" • Passed: {evidence.test_result.passed_tests}", - f" • Failed: {evidence.test_result.failed_tests}", - f" • Skipped: {evidence.test_result.skipped_tests}", - f" • Pass rate: {evidence.test_result.pass_rate:.1f}%", - "", - ] - - if evidence.test_result.coverage is not None: - report_lines.extend( - [ - "Coverage:", - f" • Coverage: {evidence.test_result.coverage:.1f}%", - f" • Threshold: {self.min_coverage:.1f}%", - f" • Status: {'✓ PASS' if evidence.test_result.coverage >= self.min_coverage else '✗ FAIL'}", - "", - ] - ) - - report_lines.extend( - [ - "Skip Pattern Check:", - f" • Violations found: {len(evidence.skip_violations)}", - f" • Status: {'✓ PASS' if evidence.skip_check_passed else '✗ FAIL'}", - "", - ] - ) - - if evidence.skip_violations: - report_lines.append(" Skip violations:") - for v in evidence.skip_violations[:5]: # Show first 5 - report_lines.append(f" - {v.file}:{v.line} - {v.pattern}") - if len(evidence.skip_violations) > 5: - report_lines.append(f" ... and {len(evidence.skip_violations) - 5} more") - report_lines.append("") - - report_lines.extend( - [ - "=" * 70, - f"VERIFICATION RESULT: {'✓ PASSED' if evidence.verified else '✗ FAILED'}", - "=" * 70, - ] - ) - - if not evidence.verified: - report_lines.extend( - [ - "", - "Errors:", - ] - ) - for error in evidence.verification_errors: - report_lines.append(f" ✗ {error}") - - report_lines.append("") - - return "\n".join(report_lines) - - def validate_claim( - self, - claim: str, - evidence: Evidence, - ) -> Dict: - """ - Validate an agent's claim against provided evidence. - - Args: - claim: What the agent is claiming (e.g., "tests pass") - evidence: Evidence provided - - Returns: - Dict with valid, claim, evidence_supports, discrepancies - """ - claim_lower = claim.lower() - - # Parse claim - claims_tests_pass = "test" in claim_lower and ( - "pass" in claim_lower or "passing" in claim_lower - ) - claims_coverage = "coverage" in claim_lower - claims_complete = "complete" in claim_lower or "done" in claim_lower - - discrepancies = [] - - # Check test passing claim - if claims_tests_pass: - if not evidence.test_result.success: - discrepancies.append( - f"Claim: 'tests pass' | Reality: {evidence.test_result.failed_tests} tests failed" - ) - - # Check coverage claim - if claims_coverage: - if evidence.test_result.coverage is None: - discrepancies.append("Claim mentions coverage | Reality: No coverage data") - elif evidence.test_result.coverage < self.min_coverage: - discrepancies.append( - f"Claim implies adequate coverage | Reality: {evidence.test_result.coverage:.1f}% (below {self.min_coverage}%)" - ) - - # Check completion claim - if claims_complete: - if not evidence.verified: - discrepancies.append( - f"Claim: 'task complete' | Reality: Verification failed with {len(evidence.verification_errors)} errors" - ) - - return { - "valid": len(discrepancies) == 0, - "claim": claim, - "evidence_supports": len(discrepancies) == 0, - "discrepancies": discrepancies, - "verified": evidence.verified, - } diff --git a/codeframe/enforcement/language_detector.py b/codeframe/enforcement/language_detector.py deleted file mode 100644 index 2b1b010a..00000000 --- a/codeframe/enforcement/language_detector.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Language Detection System - -Detects the programming language and testing framework of a project -by analyzing project files and structure. - -Supports: -- Python (pytest, unittest) -- JavaScript/TypeScript (Jest, Vitest, Mocha) -- Go (go test) -- Rust (cargo test) -- Java (JUnit, Maven, Gradle) -- Ruby (RSpec) -- C# (.NET test) -- And more... -""" - -from dataclasses import dataclass -from pathlib import Path -from typing import Optional, List -import json - - -@dataclass -class LanguageInfo: - """Information about detected language and testing framework.""" - - language: str # "python", "javascript", "typescript", "go", "rust", etc. - framework: Optional[str] # "pytest", "jest", "go test", "cargo", etc. - test_command: str # Command to run tests - coverage_command: Optional[str] # Command to get coverage - test_patterns: List[str] # File patterns for test files - skip_patterns: List[str] # Patterns that indicate skip/ignore - confidence: float # 0.0 to 1.0 - - -class LanguageDetector: - """ - Detects programming language and testing framework. - - Strategy: - 1. Check for framework-specific config files (package.json, Cargo.toml, etc.) - 2. Analyze file extensions (.py, .js, .go, .rs, etc.) - 3. Check for test directories (tests/, __tests__/, test/) - 4. Return LanguageInfo with appropriate commands - """ - - def __init__(self, project_path: str = "."): - self.project_path = Path(project_path) - - def detect(self) -> LanguageInfo: - """ - Detect language and return configuration. - - Returns: - LanguageInfo with detected language and test commands - """ - # Try each detection strategy in order of specificity - # TypeScript before JavaScript (TypeScript is more specific) - detectors = [ - self._detect_python, - self._detect_typescript, # Check TypeScript before JavaScript - self._detect_javascript, - self._detect_go, - self._detect_rust, - self._detect_java, - self._detect_ruby, - self._detect_csharp, - ] - - for detector in detectors: - result = detector() - if result and result.confidence > 0.0: # Lower threshold - return result - - # Default fallback - return LanguageInfo( - language="unknown", - framework=None, - test_command="echo 'No test framework detected'", - coverage_command=None, - test_patterns=["test_*.py", "*_test.py", "*.test.js"], - skip_patterns=[], - confidence=0.0, - ) - - def _detect_python(self) -> Optional[LanguageInfo]: - """Detect Python projects with pytest or unittest.""" - markers = [ - ("pyproject.toml", 1.0), - ("setup.py", 0.9), - ("requirements.txt", 0.7), - ("pytest.ini", 1.0), - (".pytest.ini", 1.0), - ] - - confidence = self._calculate_confidence(markers) - - if confidence > 0.0: - # Check if pytest is available - has_pytest = ( - self._file_contains("pyproject.toml", "pytest") - or (self.project_path / "pytest.ini").exists() - or (self.project_path / ".pytest.ini").exists() - ) - - return LanguageInfo( - language="python", - framework="pytest" if has_pytest else "unittest", - test_command="pytest -v" if has_pytest else "python -m unittest", - coverage_command="pytest --cov" if has_pytest else "coverage run -m unittest", - test_patterns=["test_*.py", "*_test.py", "tests/**/*.py"], - skip_patterns=[ - "@skip", - "@skipif", - "@pytest.mark.skip", - "@pytest.mark.skipif", - "@unittest.skip", - ], - confidence=confidence, - ) - - return None - - def _detect_javascript(self) -> Optional[LanguageInfo]: - """Detect JavaScript projects with Jest, Vitest, or Mocha.""" - package_json = self.project_path / "package.json" - - if not package_json.exists(): - return None - - try: - with open(package_json, "r") as f: - data = json.load(f) - - dev_deps = data.get("devDependencies", {}) - deps = data.get("dependencies", {}) - all_deps = {**deps, **dev_deps} - - # Detect framework - if "jest" in all_deps: - framework = "jest" - test_cmd = "npm test" - cov_cmd = "npm test -- --coverage" - elif "vitest" in all_deps: - framework = "vitest" - test_cmd = "npm test" - cov_cmd = "npm test -- --coverage" - elif "mocha" in all_deps: - framework = "mocha" - test_cmd = "npm test" - cov_cmd = "nyc npm test" - else: - framework = None - test_cmd = "npm test" - cov_cmd = None - - return LanguageInfo( - language="javascript", - framework=framework, - test_command=test_cmd, - coverage_command=cov_cmd, - test_patterns=["*.test.js", "*.spec.js", "__tests__/**/*.js"], - skip_patterns=[ - "it.skip", - "test.skip", - "describe.skip", - "xit", - "xtest", - "xdescribe", - ], - confidence=0.9, - ) - - except (json.JSONDecodeError, IOError): - return None - - def _detect_typescript(self) -> Optional[LanguageInfo]: - """Detect TypeScript projects.""" - tsconfig = self.project_path / "tsconfig.json" - self.project_path / "package.json" - - if not tsconfig.exists(): - return None - - # TypeScript uses same frameworks as JavaScript - js_info = self._detect_javascript() - - if js_info: - js_info.language = "typescript" - js_info.test_patterns = [ - "*.test.ts", - "*.spec.ts", - "__tests__/**/*.ts", - ] - return js_info - - return LanguageInfo( - language="typescript", - framework=None, - test_command="npm test", - coverage_command=None, - test_patterns=["*.test.ts", "*.spec.ts", "__tests__/**/*.ts"], - skip_patterns=[ - "it.skip", - "test.skip", - "describe.skip", - "xit", - "xtest", - ], - confidence=0.8, - ) - - def _detect_go(self) -> Optional[LanguageInfo]: - """Detect Go projects.""" - go_mod = self.project_path / "go.mod" - - if go_mod.exists(): - return LanguageInfo( - language="go", - framework="go test", - test_command="go test ./... -v", - coverage_command="go test ./... -cover", - test_patterns=["*_test.go"], - skip_patterns=["t.Skip(", "testing.Skip(", "// +build ignore"], - confidence=1.0, - ) - - return None - - def _detect_rust(self) -> Optional[LanguageInfo]: - """Detect Rust projects.""" - cargo_toml = self.project_path / "Cargo.toml" - - if cargo_toml.exists(): - return LanguageInfo( - language="rust", - framework="cargo test", - test_command="cargo test", - coverage_command="cargo tarpaulin --out Xml", - test_patterns=["tests/**/*.rs", "src/**/*.rs"], - skip_patterns=["#[ignore]", "#[cfg(test)]"], - confidence=1.0, - ) - - return None - - def _detect_java(self) -> Optional[LanguageInfo]: - """Detect Java projects with Maven or Gradle.""" - pom_xml = self.project_path / "pom.xml" - build_gradle = self.project_path / "build.gradle" - - if pom_xml.exists(): - return LanguageInfo( - language="java", - framework="maven", - test_command="mvn test", - coverage_command="mvn jacoco:report", - test_patterns=["**/Test*.java", "**/*Test.java"], - skip_patterns=["@Ignore", "@Disabled"], - confidence=1.0, - ) - - if build_gradle.exists(): - return LanguageInfo( - language="java", - framework="gradle", - test_command="./gradlew test", - coverage_command="./gradlew jacocoTestReport", - test_patterns=["**/Test*.java", "**/*Test.java"], - skip_patterns=["@Ignore", "@Disabled"], - confidence=1.0, - ) - - return None - - def _detect_ruby(self) -> Optional[LanguageInfo]: - """Detect Ruby projects with RSpec.""" - gemfile = self.project_path / "Gemfile" - - if gemfile.exists() and self._file_contains("Gemfile", "rspec"): - return LanguageInfo( - language="ruby", - framework="rspec", - test_command="bundle exec rspec", - coverage_command="bundle exec rspec --format documentation", - test_patterns=["spec/**/*_spec.rb"], - skip_patterns=["skip", "pending", "xit"], - confidence=0.9, - ) - - return None - - def _detect_csharp(self) -> Optional[LanguageInfo]: - """Detect C# .NET projects.""" - csproj_files = list(self.project_path.glob("*.csproj")) - - if csproj_files: - return LanguageInfo( - language="csharp", - framework="dotnet test", - test_command="dotnet test", - coverage_command="dotnet test /p:CollectCoverage=true", - test_patterns=["**/*Tests.cs", "**/Test*.cs"], - skip_patterns=["[Ignore]", "[Skip]"], - confidence=1.0, - ) - - return None - - def _calculate_confidence(self, markers: List[tuple]) -> float: - """ - Calculate confidence based on presence of marker files. - - Strategy: Return the highest weight of any found marker, - with a bonus for multiple markers. - - Args: - markers: List of (filename, weight) tuples - - Returns: - Confidence score 0.0 to 1.0 - """ - found_markers = [] - - for filename, weight in markers: - if (self.project_path / filename).exists(): - found_markers.append(weight) - - if not found_markers: - return 0.0 - - # Base confidence is the highest marker weight - base_confidence = max(found_markers) - - # Bonus for multiple markers (up to +0.2) - marker_bonus = min(0.2, (len(found_markers) - 1) * 0.1) - - return min(1.0, base_confidence + marker_bonus) - - def _file_contains(self, filename: str, text: str) -> bool: - """Check if a file contains specific text.""" - file_path = self.project_path / filename - - if not file_path.exists(): - return False - - try: - with open(file_path, "r", encoding="utf-8") as f: - return text in f.read() - except (IOError, UnicodeDecodeError): - return False diff --git a/codeframe/enforcement/quality_tracker.py b/codeframe/enforcement/quality_tracker.py deleted file mode 100644 index 96059e2b..00000000 --- a/codeframe/enforcement/quality_tracker.py +++ /dev/null @@ -1,323 +0,0 @@ -""" -Generic Quality Tracker - -Tracks quality metrics across sessions for ANY language, not just Python. -This is the language-agnostic version of scripts/quality-ratchet.py. - -Metrics tracked: -- Test pass rate -- Coverage percentage -- Response count (AI conversation length) -- Timestamp - -Stored in: .codeframe/quality_history.json (project-specific) -""" - -import json -from dataclasses import dataclass, asdict -from pathlib import Path -from typing import List, Optional, Dict - - -@dataclass -class QualityMetrics: - """Quality metrics snapshot.""" - - timestamp: str # ISO format timestamp - response_count: int # Number of AI responses - test_pass_rate: float # Percentage of tests passing (0-100) - coverage_percentage: float # Code coverage percentage (0-100) - total_tests: int # Total number of tests - passed_tests: int # Number of passed tests - failed_tests: int # Number of failed tests - language: Optional[str] = None # Language being worked on - framework: Optional[str] = None # Test framework used - - -class QualityTracker: - """ - Track quality metrics across AI conversation sessions. - - Works with ANY language - adapts to whatever the agent is working on. - - Usage: - tracker = QualityTracker(project_path="/path/to/project") - - # Record a checkpoint - metrics = QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=5, - test_pass_rate=95.0, - coverage_percentage=87.5, - total_tests=100, - passed_tests=95, - failed_tests=5, - language="python", - framework="pytest" - ) - tracker.record(metrics) - - # Check for degradation - degradation = tracker.check_degradation() - if degradation["has_degradation"]: - print("Quality degraded! Recommend context reset.") - """ - - def __init__(self, project_path: str = "."): - self.project_path = Path(project_path) - self.history_file = self.project_path / ".codeframe" / "quality_history.json" - - def record(self, metrics: QualityMetrics) -> None: - """ - Record a quality checkpoint. - - Args: - metrics: QualityMetrics to record - """ - history = self.load_history() - history.append(asdict(metrics)) - self.save_history(history) - - def load_history(self) -> List[Dict]: - """ - Load quality history from JSON file. - - Returns: - List of quality checkpoint dictionaries - """ - if not self.history_file.exists(): - return [] - - try: - with open(self.history_file, "r") as f: - return json.load(f) - except (json.JSONDecodeError, IOError): - return [] - - def save_history(self, history: List[Dict]) -> None: - """ - Save quality history to JSON file. - - Args: - history: List of quality checkpoints - """ - # Ensure directory exists - self.history_file.parent.mkdir(parents=True, exist_ok=True) - - with open(self.history_file, "w") as f: - json.dump(history, f, indent=2) - - def check_degradation(self, threshold_percent: float = 10.0) -> Dict: - """ - Check if quality has degraded from peak. - - Degradation is detected when: - - Recent metrics < Peak - threshold_percent - - Args: - threshold_percent: Degradation threshold (default: 10%) - - Returns: - Dict with has_degradation, issues, recommendations - """ - history = self.load_history() - - if len(history) < 2: - return { - "has_degradation": False, - "message": "Not enough data (need at least 2 checkpoints)", - } - - # Find peak quality - peak = self._find_peak(history) - - # Get recent metrics (last checkpoint or average of last 3) - if len(history) < 3: - recent = history[-1] - else: - recent = self._calculate_moving_average(history[-3:]) - - # Check for degradation - coverage_drop = peak["coverage_percentage"] - recent["coverage_percentage"] - pass_rate_drop = peak["test_pass_rate"] - recent["test_pass_rate"] - - has_coverage_degradation = coverage_drop > threshold_percent - has_pass_rate_degradation = pass_rate_drop > threshold_percent - - if has_coverage_degradation or has_pass_rate_degradation: - issues = [] - if has_coverage_degradation: - issues.append( - f"Coverage: {recent['coverage_percentage']:.1f}% " - f"(peak: {peak['coverage_percentage']:.1f}%, " - f"drop: {coverage_drop:.1f}%)" - ) - if has_pass_rate_degradation: - issues.append( - f"Pass rate: {recent['test_pass_rate']:.1f}% " - f"(peak: {peak['test_pass_rate']:.1f}%, " - f"drop: {pass_rate_drop:.1f}%)" - ) - - return { - "has_degradation": True, - "coverage_drop": coverage_drop, - "pass_rate_drop": pass_rate_drop, - "issues": issues, - "recommendation": "Consider context reset - quality has degraded significantly", - "peak": peak, - "recent": recent, - } - - return { - "has_degradation": False, - "message": "Quality stable", - "peak": peak, - "recent": recent, - } - - def get_stats(self) -> Dict: - """ - Get quality statistics. - - Returns: - Dict with current, peak, average metrics - """ - history = self.load_history() - - if not history: - return { - "has_data": False, - "message": "No quality data recorded yet", - } - - current = history[-1] - peak = self._find_peak(history) - average = self._calculate_moving_average(history[-3:] if len(history) >= 3 else history) - - return { - "has_data": True, - "total_checkpoints": len(history), - "current": current, - "peak": peak, - "average": average, - "trend": self._calculate_trend(history), - } - - def reset(self) -> None: - """Clear all quality history.""" - self.save_history([]) - - def _find_peak(self, history: List[Dict]) -> Dict: - """ - Find the peak quality checkpoint. - - Peak is defined by highest combined score: - score = (test_pass_rate + coverage_percentage) / 2 - - Args: - history: List of checkpoints - - Returns: - Peak checkpoint dictionary - """ - - def score(checkpoint: Dict) -> float: - return ( - checkpoint.get("test_pass_rate", 0) + checkpoint.get("coverage_percentage", 0) - ) / 2 - - return max(history, key=score) - - def _calculate_moving_average(self, checkpoints: List[Dict]) -> Dict: - """ - Calculate moving average of metrics. - - Args: - checkpoints: List of checkpoints to average - - Returns: - Dict with averaged metrics - """ - if not checkpoints: - return { - "test_pass_rate": 0.0, - "coverage_percentage": 0.0, - "total_tests": 0, - } - - n = len(checkpoints) - - return { - "test_pass_rate": sum(c.get("test_pass_rate", 0) for c in checkpoints) / n, - "coverage_percentage": sum(c.get("coverage_percentage", 0) for c in checkpoints) / n, - "total_tests": int(sum(c.get("total_tests", 0) for c in checkpoints) / n), - "passed_tests": int(sum(c.get("passed_tests", 0) for c in checkpoints) / n), - "failed_tests": int(sum(c.get("failed_tests", 0) for c in checkpoints) / n), - } - - def _calculate_trend(self, history: List[Dict]) -> str: - """ - Calculate quality trend. - - Args: - history: List of checkpoints - - Returns: - "improving", "stable", or "declining" - """ - if len(history) < 3: - return "insufficient_data" - - recent_3 = history[-3:] - scores = [ - (c.get("test_pass_rate", 0) + c.get("coverage_percentage", 0)) / 2 for c in recent_3 - ] - - # Simple trend: compare first and last - if scores[-1] > scores[0] + 2: - return "improving" - elif scores[-1] < scores[0] - 2: - return "declining" - else: - return "stable" - - def should_reset_context( - self, - response_count: int, - max_responses: int = 20, - check_degradation: bool = True, - ) -> Dict: - """ - Determine if context should be reset. - - Reset triggers: - 1. Response count exceeds maximum - 2. Quality degradation detected - 3. Explicit request - - Args: - response_count: Current response count - max_responses: Maximum responses before reset (default: 20) - check_degradation: Whether to check for quality degradation - - Returns: - Dict with should_reset, reasons - """ - reasons = [] - - # Check response count - if response_count >= max_responses: - reasons.append(f"Response count ({response_count}) exceeds maximum ({max_responses})") - - # Check quality degradation - if check_degradation: - degradation = self.check_degradation() - if degradation["has_degradation"]: - reasons.append(f"Quality degradation detected: {degradation['issues']}") - - return { - "should_reset": len(reasons) > 0, - "reasons": reasons, - "recommendation": ("Context reset recommended" if reasons else "Context can continue"), - } diff --git a/codeframe/enforcement/skip_pattern_detector.py b/codeframe/enforcement/skip_pattern_detector.py deleted file mode 100644 index 8d64bc0e..00000000 --- a/codeframe/enforcement/skip_pattern_detector.py +++ /dev/null @@ -1,457 +0,0 @@ -""" -Multi-Language Skip Pattern Detector - -Detects skip/ignore patterns across multiple programming languages. -This is the language-agnostic version of scripts/detect-skip-abuse.py. - -Supports: -- Python: @skip, @pytest.mark.skip, @unittest.skip -- JavaScript/TypeScript: it.skip, test.skip, describe.skip, xit, xtest -- Go: t.Skip(), testing.Skip(), build tags -- Rust: #[ignore] -- Java: @Ignore, @Disabled -- Ruby: skip, pending, xit -- C#: [Ignore], [Skip] -""" - -import re -from dataclasses import dataclass -from pathlib import Path -from typing import List, Optional, Dict -import ast - -from .language_detector import LanguageDetector, LanguageInfo - - -@dataclass -class SkipViolation: - """Represents a detected skip pattern.""" - - file: str # File path - line: int # Line number - pattern: str # The skip pattern found (e.g., "@skip", "it.skip") - context: str # Surrounding code context - reason: Optional[str] # Reason if provided - severity: str # "error" or "warning" - - -class SkipPatternDetector: - """ - Detects skip patterns across multiple languages. - - Usage: - detector = SkipPatternDetector(project_path="/path/to/project") - violations = detector.detect_all() - - for v in violations: - print(f"{v.file}:{v.line} - {v.pattern}") - """ - - def __init__(self, project_path: str = "."): - self.project_path = Path(project_path) - self.language_detector = LanguageDetector(project_path) - self.language_info: Optional[LanguageInfo] = None - - def detect_all(self) -> List[SkipViolation]: - """ - Detect all skip violations in the project. - - Returns: - List of SkipViolation objects - """ - # Detect language first - if not self.language_info: - self.language_info = self.language_detector.detect() - - violations = [] - - # Find test files based on language patterns - test_files = self._find_test_files() - - # Check each test file - for test_file in test_files: - file_violations = self._check_file(test_file) - violations.extend(file_violations) - - return violations - - def _find_test_files(self) -> List[Path]: - """Find test files based on detected language patterns.""" - if not self.language_info: - return [] - - test_files = [] - - for pattern in self.language_info.test_patterns: - # Handle glob patterns - if "**/" in pattern: - # For patterns like "tests/**/*.rs" or "**/*.test.js" - # Split on **/ and use the part after it with rglob - parts = pattern.split("**/", 1) - if len(parts) == 2: - base_dir = parts[0] if parts[0] else "." - file_pattern = parts[1] - - # If base_dir is specified, search within it, otherwise search from project root - if base_dir and base_dir != ".": - search_path = self.project_path / base_dir - if search_path.exists(): - test_files.extend(search_path.rglob(file_pattern)) - else: - test_files.extend(self.project_path.rglob(file_pattern)) - else: - # Simple glob pattern without ** - test_files.extend(self.project_path.glob(pattern)) - - return test_files - - def _check_file(self, file_path: Path) -> List[SkipViolation]: - """ - Check a single file for skip patterns. - - Args: - file_path: Path to file to check - - Returns: - List of violations found in this file - """ - if not self.language_info: - return [] - - language = self.language_info.language - - # Use language-specific checker - if language == "python": - return self._check_python_file(file_path) - elif language in ["javascript", "typescript"]: - return self._check_javascript_file(file_path) - elif language == "go": - return self._check_go_file(file_path) - elif language == "rust": - return self._check_rust_file(file_path) - elif language == "java": - return self._check_java_file(file_path) - elif language == "ruby": - return self._check_ruby_file(file_path) - elif language == "csharp": - return self._check_csharp_file(file_path) - else: - # Generic regex-based checking - return self._check_generic_file(file_path) - - def _check_python_file(self, file_path: Path) -> List[SkipViolation]: - """Check Python file using AST parsing.""" - violations = [] - - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - tree = ast.parse(content, filename=str(file_path)) - - # Use AST visitor to find decorators - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef): - for decorator in node.decorator_list: - skip_info = self._check_python_decorator(decorator) - if skip_info: - violations.append( - SkipViolation( - file=str(file_path), - line=node.lineno, - pattern=skip_info["pattern"], - context=node.name, - reason=skip_info.get("reason"), - severity="error", - ) - ) - - except (SyntaxError, FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _check_python_decorator(self, decorator: ast.expr) -> Optional[Dict]: - """Check if a Python decorator is a skip decorator.""" - # Case 1: @skip or @skipif - if isinstance(decorator, ast.Name): - if decorator.id in ("skip", "skipif"): - return {"pattern": f"@{decorator.id}", "reason": None} - - # Case 2: @skip(reason="...") or @skipif(...) - elif isinstance(decorator, ast.Call): - if isinstance(decorator.func, ast.Name): - if decorator.func.id in ("skip", "skipif"): - reason = self._extract_reason_python(decorator) - return {"pattern": f"@{decorator.func.id}", "reason": reason} - - # Case 3: @pytest.mark.skip or @unittest.skip - elif isinstance(decorator.func, ast.Attribute): - if self._is_skip_attribute(decorator.func): - reason = self._extract_reason_python(decorator) - return { - "pattern": f"@{self._get_full_name(decorator.func)}", - "reason": reason, - } - - # Case 4: @pytest.mark.skip (without call) - elif isinstance(decorator, ast.Attribute): - if self._is_skip_attribute(decorator): - return {"pattern": f"@{self._get_full_name(decorator)}", "reason": None} - - return None - - def _is_skip_attribute(self, attr: ast.Attribute) -> bool: - """Check if attribute is a skip-related attribute.""" - if attr.attr in ("skip", "skipif"): - # Check for pytest.mark.skip, unittest.skip - if isinstance(attr.value, ast.Attribute): - return True - elif isinstance(attr.value, ast.Name): - return attr.value.id in ("pytest", "unittest") - return False - - def _get_full_name(self, attr: ast.Attribute) -> str: - """Get full name of attribute (e.g., pytest.mark.skip).""" - parts = [attr.attr] - current = attr.value - - while isinstance(current, ast.Attribute): - parts.insert(0, current.attr) - current = current.value - - if isinstance(current, ast.Name): - parts.insert(0, current.id) - - return ".".join(parts) - - def _extract_reason_python(self, call: ast.Call) -> Optional[str]: - """Extract reason from Python skip decorator.""" - for keyword in call.keywords: - if keyword.arg == "reason": - if isinstance(keyword.value, ast.Constant): - return keyword.value.value - return None - - def _check_javascript_file(self, file_path: Path) -> List[SkipViolation]: - """Check JavaScript/TypeScript file for skip patterns.""" - violations = [] - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - patterns = [ - r"\bit\.skip\s*\(", - r"\btest\.skip\s*\(", - r"\bdescribe\.skip\s*\(", - r"\bxit\s*\(", - r"\bxtest\s*\(", - r"\bxdescribe\s*\(", - ] - - for line_num, line in enumerate(lines, start=1): - for pattern in patterns: - if re.search(pattern, line): - violations.append( - SkipViolation( - file=str(file_path), - line=line_num, - pattern=pattern.replace(r"\b", "").replace(r"\s*\(", ""), - context=line.strip(), - reason=self._extract_reason_from_line(line), - severity="error", - ) - ) - - except (FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _check_go_file(self, file_path: Path) -> List[SkipViolation]: - """Check Go file for skip patterns.""" - violations = [] - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - patterns = [ - r"t\.Skip\s*\(", - r"testing\.Skip\s*\(", - r"//\s*\+build\s+ignore", - ] - - for line_num, line in enumerate(lines, start=1): - for pattern in patterns: - if re.search(pattern, line): - violations.append( - SkipViolation( - file=str(file_path), - line=line_num, - pattern=pattern.replace(r"\s*\(", ""), - context=line.strip(), - reason=self._extract_reason_from_line(line), - severity="error", - ) - ) - - except (FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _check_rust_file(self, file_path: Path) -> List[SkipViolation]: - """Check Rust file for #[ignore] attribute.""" - violations = [] - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - pattern = r"#\s*\[\s*ignore\s*\]" - - for line_num, line in enumerate(lines, start=1): - if re.search(pattern, line): - violations.append( - SkipViolation( - file=str(file_path), - line=line_num, - pattern="#[ignore]", - context=line.strip(), - reason=None, - severity="error", - ) - ) - - except (FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _check_java_file(self, file_path: Path) -> List[SkipViolation]: - """Check Java file for @Ignore or @Disabled annotations.""" - violations = [] - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - patterns = [r"@Ignore", r"@Disabled"] - - for line_num, line in enumerate(lines, start=1): - for pattern in patterns: - if re.search(pattern, line): - violations.append( - SkipViolation( - file=str(file_path), - line=line_num, - pattern=pattern, - context=line.strip(), - reason=self._extract_reason_from_line(line), - severity="error", - ) - ) - - except (FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _check_ruby_file(self, file_path: Path) -> List[SkipViolation]: - """Check Ruby/RSpec file for skip patterns.""" - violations = [] - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - patterns = [r"\bskip\s+", r"\bpending\s+", r"\bxit\s+"] - - for line_num, line in enumerate(lines, start=1): - for pattern in patterns: - if re.search(pattern, line): - violations.append( - SkipViolation( - file=str(file_path), - line=line_num, - pattern=pattern.replace(r"\b", "").replace(r"\s+", ""), - context=line.strip(), - reason=self._extract_reason_from_line(line), - severity="error", - ) - ) - - except (FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _check_csharp_file(self, file_path: Path) -> List[SkipViolation]: - """Check C# file for [Ignore] or [Skip] attributes.""" - violations = [] - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - # Match [Ignore] or [Ignore("reason")] and [Skip] or [Skip("reason")] - patterns = [r"\[Ignore(?:\]|\()", r"\[Skip(?:\]|\()"] - - for line_num, line in enumerate(lines, start=1): - for pattern in patterns: - if re.search(pattern, line): - violations.append( - SkipViolation( - file=str(file_path), - line=line_num, - pattern=pattern, - context=line.strip(), - reason=self._extract_reason_from_line(line), - severity="error", - ) - ) - - except (FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _check_generic_file(self, file_path: Path) -> List[SkipViolation]: - """Generic check using configured skip patterns.""" - violations = [] - - if not self.language_info: - return violations - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - - for line_num, line in enumerate(lines, start=1): - for pattern in self.language_info.skip_patterns: - if pattern in line: - violations.append( - SkipViolation( - file=str(file_path), - line=line_num, - pattern=pattern, - context=line.strip(), - reason=None, - severity="warning", # Lower severity for generic - ) - ) - - except (FileNotFoundError, UnicodeDecodeError): - pass - - return violations - - def _extract_reason_from_line(self, line: str) -> Optional[str]: - """Extract reason string from a line of code.""" - # Look for strings in quotes - string_match = re.search(r'["\']([^"\']+)["\']', line) - if string_match: - return string_match.group(1) - return None diff --git a/codeframe/git/__init__.py b/codeframe/git/__init__.py index b81c1eca..f63be6bd 100644 --- a/codeframe/git/__init__.py +++ b/codeframe/git/__init__.py @@ -1,5 +1,5 @@ -"""Git workflow management for CodeFRAME.""" +"""Git integration for CodeFRAME. -from codeframe.git.workflow_manager import GitWorkflowManager - -__all__ = ["GitWorkflowManager"] +The GitHub API client lives in :mod:`codeframe.git.github_integration` and is +imported directly where needed (``cf pr`` commands, the ``pr_v2`` router). +""" diff --git a/codeframe/git/workflow_manager.py b/codeframe/git/workflow_manager.py deleted file mode 100644 index d08dbae3..00000000 --- a/codeframe/git/workflow_manager.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Git workflow manager for CodeFRAME issues and feature branches. - -Manages git branching and merge workflows: -- Feature branch creation per issue -- Auto-merge to main when all tasks complete -- Database tracking of branches -""" - -import re -import logging -from pathlib import Path -from typing import Dict, Any, List -import git - -from codeframe.persistence.database import Database - -logger = logging.getLogger(__name__) - - -class GitWorkflowManager: - """Manages git branching and merge workflows for CodeFRAME issues.""" - - def __init__(self, project_root: Path, db: Database): - """Initialize GitWorkflowManager. - - Args: - project_root: Path to project root (must be a git repository) - db: Database instance for tracking branches - - Raises: - git.InvalidGitRepositoryError: If project_root is not a git repository - git.NoSuchPathError: If project_root does not exist - """ - self.project_root = Path(project_root) - self.db = db - - # Initialize git repo - self.repo = git.Repo(self.project_root) - - logger.info(f"Initialized GitWorkflowManager for {self.project_root}") - - def create_feature_branch(self, issue_number: str, issue_title: str) -> str: - """Create feature branch for an issue. - - Branch naming convention: issue-{issue_number}-{sanitized-title} - Example: issue-2.1-user-authentication - - Args: - issue_number: Issue number (e.g., "2.1", "3.5") - issue_title: Issue title for branch name - - Returns: - Branch name created (e.g., "issue-2.1-user-authentication") - - Raises: - ValueError: If issue_number or issue_title is empty/invalid - ValueError: If branch already exists - """ - # Validate inputs - if not issue_number or not issue_number.strip(): - raise ValueError("Issue number cannot be empty") - if not issue_title or not issue_title.strip(): - raise ValueError("Issue title cannot be empty") - - # Sanitize issue number and title for branch name - issue_number_clean = issue_number.strip() - title_clean = self._sanitize_branch_name(issue_title) - - # Construct branch name - branch_name = f"issue-{issue_number_clean}-{title_clean}" - - # Truncate if too long (git ref name limit ~63 chars) - if len(branch_name) > 63: - # Keep issue prefix, truncate title part - prefix = f"issue-{issue_number_clean}-" - max_title_len = 63 - len(prefix) - title_clean = title_clean[:max_title_len] - branch_name = f"{prefix}{title_clean}" - - # Check if branch already exists - if branch_name in [b.name for b in self.repo.branches]: - raise ValueError(f"Branch '{branch_name}' already exists") - - # Create branch from current HEAD - self.repo.create_head(branch_name) - - logger.info(f"Created feature branch: {branch_name}") - - # Store in database (if issue exists) - try: - # Find issue by issue_number across all projects - # Get all projects to find matching issue - projects = self.db.list_projects() - matching_issue = None - - for project in projects: - issues = self.db.get_project_issues(project["id"]) - matches = [i for i in issues if i.issue_number == issue_number_clean] - if matches: - matching_issue = matches[0] - break - - if matching_issue: - self.db.create_git_branch(matching_issue.id, branch_name) - logger.debug( - f"Stored branch {branch_name} in database for issue {matching_issue.id}" - ) - except Exception as e: - logger.warning(f"Could not store branch in database: {e}") - # Don't fail if database tracking fails - - return branch_name - - def _sanitize_branch_name(self, title: str) -> str: - """Sanitize title for use in git branch name. - - Args: - title: Raw title string - - Returns: - Sanitized string safe for git branch names - """ - # Convert to lowercase - sanitized = title.lower() - - # Replace spaces and special characters with hyphens - sanitized = re.sub(r"[^\w\s-]", "", sanitized) # Remove special chars - sanitized = re.sub(r"[\s_]+", "-", sanitized) # Replace spaces/underscores with hyphens - sanitized = re.sub(r"-+", "-", sanitized) # Collapse multiple hyphens - sanitized = sanitized.strip("-") # Remove leading/trailing hyphens - - return sanitized - - async def merge_to_main(self, issue_number: str) -> Dict[str, Any]: - """Merge feature branch to main after all tasks complete. - - Args: - issue_number: Issue number to merge (e.g., "2.1") - - Returns: - dict with merge_commit, branch_name, status - - Raises: - ValueError: If issue not found or tasks incomplete - git.GitCommandError: If merge conflicts occur - """ - # Find issue by number across all projects - projects = self.db.list_projects() - matching_issue = None - - for project in projects: - issues = self.db.get_project_issues(project["id"]) - matches = [i for i in issues if i.issue_number == issue_number] - if matches: - matching_issue = matches[0] - break - - if not matching_issue: - raise ValueError(f"Issue {issue_number} not found") - - issue_id = matching_issue.id - - # Check all tasks are completed (async) - if not await self.is_issue_complete(issue_id): - raise ValueError(f"Cannot merge issue {issue_number}: incomplete tasks remain") - - # Get branch name from database - branch_record = self.db.get_branch_for_issue(issue_id) - if not branch_record: - raise ValueError(f"No branch found for issue {issue_number}") - - branch_name = branch_record["branch_name"] - - # Ensure we're on main/master branch - try: - main_branch = self.repo.heads['main'] - except (AttributeError, IndexError): - # Fall back to master if main doesn't exist - main_branch = self.repo.heads['master'] - - main_branch.checkout() - - # Perform merge - try: - # Merge with no-ff to preserve branch history - self.repo.git.merge(branch_name, no_ff=True, m=f"Merge {branch_name}") - - # Get merge commit - merge_commit = self.repo.head.commit.hexsha - - logger.info(f"Merged {branch_name} to main: {merge_commit}") - - # Update database - self.db.mark_branch_merged(branch_record["id"], merge_commit) - - return { - "status": "merged", - "branch_name": branch_name, - "merge_commit": merge_commit, - } - - except git.GitCommandError as e: - logger.error(f"Merge conflict occurred: {e}") - # Abort merge - try: - self.repo.git.merge("--abort") - except Exception: # noqa: S110 - pass - raise - - async def is_issue_complete(self, issue_id: int) -> bool: - """Check if all tasks for an issue are completed. - - Args: - issue_id: Issue ID - - Returns: - True if all tasks completed, False otherwise - """ - tasks = await self.db.get_tasks_by_issue(issue_id) - - # No tasks = incomplete - if not tasks: - return False - - # Check all tasks are completed - from codeframe.core.models import TaskStatus - all_completed = all(task.status == TaskStatus.COMPLETED for task in tasks) - - logger.debug( - f"Issue {issue_id} completion check: {len(tasks)} tasks, " f"completed={all_completed}" - ) - - return all_completed - - def get_current_branch(self) -> str: - """Get current git branch name. - - Returns: - Current branch name, or "HEAD detached at {sha}" if in detached HEAD state - """ - try: - return self.repo.active_branch.name - except TypeError: - # Detached HEAD state - commit_sha = self.repo.head.commit.hexsha[:7] - return f"HEAD detached at {commit_sha}" - - def checkout_branch(self, branch_name: str) -> None: - """Checkout a git branch. - - Args: - branch_name: Name of branch to checkout - - Raises: - git.GitCommandError: If branch does not exist or checkout fails - """ - self.repo.git.checkout(branch_name) - logger.info(f"Checked out branch: {branch_name}") - - def _infer_commit_type(self, title: str, description: str) -> str: - """Infer conventional commit type from task title and description. - - Args: - title: Task title - description: Task description - - Returns: - Commit type (feat, fix, refactor, test, docs, chore) - """ - # Combine title and description for keyword matching - text = (title + " " + (description or "")).lower() - - # Check for keywords - if any(kw in text for kw in ["fix", "bug", "repair", "correct"]): - return "fix" - elif any(kw in text for kw in ["test", "testing", "spec"]): - return "test" - elif any(kw in text for kw in ["refactor", "restructure", "reorganize"]): - return "refactor" - elif any(kw in text for kw in ["document", "docs", "readme"]): - return "docs" - elif any(kw in text for kw in ["chore", "config", "setup"]): - return "chore" - else: - # Default to "feat" for implementation tasks - return "feat" - - def _generate_commit_message(self, task: Dict[str, Any], files_modified: List[str]) -> str: - """Generate conventional commit message from task. - - Args: - task: Task dictionary with task_number, title, description - files_modified: List of modified file paths - - Returns: - Conventional commit message string - """ - # Infer commit type - commit_type = self._infer_commit_type(task["title"], task.get("description", "")) - - # Build message - scope = task["task_number"] - subject = task["title"] - - # Start with conventional format - message = f"{commit_type}({scope}): {subject}" - - # Add body with description if present - if task.get("description"): - message += f"\n\n{task['description']}" - - # Add file listing - if files_modified: - message += "\n\nModified files:" - for file_path in files_modified: - message += f"\n- {file_path}" - - return message - - def commit_task_changes( - self, task: Dict[str, Any], files_modified: List[str], agent_id: str - ) -> str: - """Create git commit for task changes and record in changelog. - - Args: - task: Task dictionary with id, project_id, task_number, title, description - files_modified: List of file paths that were modified - agent_id: ID of agent making the commit - - Returns: - Commit SHA hash - - Raises: - ValueError: If files_modified is empty or working tree is dirty - KeyError: If task missing required fields - git.GitCommandError: If git operations fail - """ - # Validate inputs - if not files_modified: - raise ValueError("No files to commit") - - # Ensure required task fields exist - required_fields = ["id", "project_id", "task_number", "title"] - for field in required_fields: - if field not in task: - raise KeyError(f"Task missing required field: {field}") - - # T079: Check for dirty working tree BEFORE staging files - # Note: We allow modified files that we're about to stage, but not untracked files - # or other modifications that aren't part of files_modified - if self.repo.is_dirty(untracked_files=True): - # Check if the dirty files are the ones we're about to commit - dirty_files = [item.a_path for item in self.repo.index.diff(None)] - dirty_files.extend(self.repo.untracked_files) - - # Remove files we're about to stage from dirty list - non_staged_dirty = [f for f in dirty_files if f not in files_modified] - - if non_staged_dirty: - raise ValueError( - f"Working tree is dirty - cannot commit. " - f"Unrelated changes detected: {', '.join(non_staged_dirty[:3])}" - ) - - # Generate commit message - commit_message = self._generate_commit_message(task, files_modified) - - # Stage files - self.repo.index.add(files_modified) - - # Create commit - commit = self.repo.index.commit(commit_message) - commit_hash = commit.hexsha - - logger.info(f"Created commit {commit_hash[:7]} for task {task['task_number']}") - - # Record in changelog - import json - - try: - details = json.dumps( - { - "commit_hash": commit_hash, - "commit_message": commit_message, - "files_modified": files_modified, - } - ) - - cursor = self.db.conn.cursor() - cursor.execute( - """ - INSERT INTO changelog (project_id, agent_id, task_id, action, details) - VALUES (?, ?, ?, ?, ?) - """, - (task["project_id"], agent_id, task["id"], "commit", details), - ) - self.db.conn.commit() - - logger.debug(f"Recorded commit in changelog for task {task['id']}") - except Exception as e: - logger.warning(f"Failed to record commit in changelog: {e}") - # Don't fail the commit if changelog recording fails - - return commit_hash diff --git a/codeframe/persistence/database.py b/codeframe/persistence/database.py index c0f0ba70..8e4bbf13 100644 --- a/codeframe/persistence/database.py +++ b/codeframe/persistence/database.py @@ -1,16 +1,21 @@ -"""Database management for CodeFRAME state. +"""Control-plane database management for CodeFRAME. -Refactored to use domain-specific repositories for better maintainability. -The Database class now acts as a facade, delegating operations to repositories. +The global database is a **control-plane store** only: auth (users/accounts/ +sessions/verification), API keys, audit logs, interactive sessions, and token +usage. All v2 domain data (tasks/blockers/PRD/...) lives in the per-workspace +``.codeframe/state.db`` via ``codeframe.core.workspace`` — not here. + +The class acts as a thin facade, delegating to the surviving control-plane +repositories. Supports both synchronous (sqlite3) and asynchronous (aiosqlite) +operations. """ import contextlib import os import sqlite3 import threading -from datetime import datetime from pathlib import Path -from typing import Optional, TYPE_CHECKING +from typing import Optional import logging import asyncio @@ -18,31 +23,12 @@ from codeframe.persistence.schema_manager import SchemaManager from codeframe.persistence.repositories import ( - ProjectRepository, - IssueRepository, - TaskRepository, - AgentRepository, - BlockerRepository, - MemoryRepository, - ContextRepository, - CheckpointRepository, - GitRepository, - TestRepository, - LintRepository, - ReviewRepository, - QualityRepository, TokenRepository, - CorrectionRepository, - ActivityRepository, AuditRepository, - PRRepository, APIKeyRepository, ) from codeframe.persistence.repositories.interactive_sessions import InteractiveSessionRepository -if TYPE_CHECKING: - pass - logger = logging.getLogger(__name__) # Audit verbosity configuration @@ -53,31 +39,13 @@ class Database: - """SQLite database manager for project state. - - This class acts as a facade, delegating operations to domain-specific repositories. - All methods maintain 100% backward compatibility with the original monolithic Database class. + """SQLite manager for the global control-plane store. Repositories: - - projects: Project management (create, update, delete, list) - - issues: Issue tracking and management - - tasks: Task lifecycle and dependencies - - agents: Agent creation and assignment - - blockers: Human-in-the-loop blocking questions - - memories: Conversation and decision memory - - context_items: Context management for long-running sessions - - checkpoints: Project state checkpoints - - git_branches: Git branch tracking - - test_results: Test execution results - - lint_results: Linting results - - code_reviews: Code review findings - - quality_gates: Quality gate status - - token_usage: LLM token usage tracking - - correction_attempts: Error correction tracking - - activities: Activity logs and PRD + - api_keys: API key issuance and lookup - audit_logs: Audit logging - - Supports both synchronous (sqlite3) and asynchronous (aiosqlite) operations. + - interactive_sessions: Interactive agent session records + - token_usage: LLM token usage tracking (also used per-workspace) """ def __init__(self, db_path: Path | str): @@ -92,25 +60,9 @@ def __init__(self, db_path: Path | str): self._async_lock = asyncio.Lock() self._sync_lock = threading.RLock() # Reentrant lock for thread-safe access - # Initialize repositories (will be set after connections are created) - self.projects: Optional[ProjectRepository] = None - self.issues: Optional[IssueRepository] = None - self.tasks: Optional[TaskRepository] = None - self.agents: Optional[AgentRepository] = None - self.blockers: Optional[BlockerRepository] = None - self.memories: Optional[MemoryRepository] = None - self.context_items: Optional[ContextRepository] = None - self.checkpoints: Optional[CheckpointRepository] = None - self.git_branches: Optional[GitRepository] = None - self.test_results: Optional[TestRepository] = None - self.lint_results: Optional[LintRepository] = None - self.code_reviews: Optional[ReviewRepository] = None - self.quality_gates: Optional[QualityRepository] = None + # Control-plane repositories (set after connections are created) self.token_usage: Optional[TokenRepository] = None - self.correction_attempts: Optional[CorrectionRepository] = None - self.activities: Optional[ActivityRepository] = None self.audit_logs: Optional[AuditRepository] = None - self.pull_requests: Optional[PRRepository] = None self.api_keys: Optional[APIKeyRepository] = None self.interactive_sessions: Optional[InteractiveSessionRepository] = None @@ -138,41 +90,14 @@ def initialize(self) -> None: def _initialize_repositories(self) -> None: """Initialize all repository instances.""" - # Pass both sync and async connections to support mixed operations - # Also pass self (Database instance) for cross-repository operations - # Pass sync_lock for thread-safe access to the shared connection - self.projects = ProjectRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.issues = IssueRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.tasks = TaskRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.agents = AgentRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.blockers = BlockerRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.memories = MemoryRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.context_items = ContextRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.checkpoints = CheckpointRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.git_branches = GitRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.test_results = TestRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.lint_results = LintRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.code_reviews = ReviewRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.quality_gates = QualityRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) + # Pass both sync and async connections to support mixed operations. + # Also pass self (Database instance) for cross-repository operations, + # and sync_lock for thread-safe access to the shared connection. self.token_usage = TokenRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.correction_attempts = CorrectionRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.activities = ActivityRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) self.audit_logs = AuditRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - self.pull_requests = PRRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) self.api_keys = APIKeyRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) self.interactive_sessions = InteractiveSessionRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock) - # Backward compatibility properties (maintain old *_repository naming) - @property - def task_repository(self) -> TaskRepository: - """Backward compatibility: Access tasks repository.""" - return self.tasks - - @property - def blocker_repository(self) -> BlockerRepository: - """Backward compatibility: Access blockers repository.""" - return self.blockers - # Connection management methods def close(self) -> None: """Close database connection (sync only).""" @@ -213,16 +138,12 @@ async def initialize_async(self) -> None: await self._async_conn.execute("PRAGMA busy_timeout = 5000") logger.debug(f"Async connection initialized for {self.db_path}") # Update repository async connections - if self.projects: + if self.token_usage: self._update_repository_async_connections() def _update_repository_async_connections(self) -> None: """Update async connections in all repositories.""" - for repo in [self.projects, self.issues, self.tasks, self.agents, self.blockers, - self.memories, self.context_items, self.checkpoints, self.git_branches, - self.test_results, self.lint_results, self.code_reviews, self.quality_gates, - self.token_usage, self.correction_attempts, self.activities, self.audit_logs, - self.pull_requests, self.interactive_sessions]: + for repo in [self.token_usage, self.audit_logs, self.api_keys, self.interactive_sessions]: if repo: repo._async_conn = self._async_conn @@ -286,12 +207,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: def transaction(self): """Context manager for explicit transaction control. - Usage: - with db.transaction(): - db.create_issue(...) - db.create_task_with_issue(...) - # All operations committed together, or rolled back on error - Yields: self: The database instance for chaining operations @@ -327,426 +242,12 @@ def transaction(self): finally: self.conn.isolation_level = old_isolation - # Backward compatibility: Parse datetime helper (used by many tests) - def _parse_datetime( - self, value: str, field_name: str, row_id: Optional[int] = None - ) -> Optional[datetime]: - """Parse ISO datetime string with logging for failures.""" - if not value: - return None - try: - return datetime.fromisoformat(value) - except (ValueError, TypeError) as e: - row_context = f" (row {row_id})" if row_id else "" - logger.warning( - f"Failed to parse {field_name}{row_context}: '{value}', error: {e}" - ) - return None - - def create_project(self, *args, **kwargs): - """Delegate to projects.create_project().""" - return self.projects.create_project(*args, **kwargs) - - def get_project(self, *args, **kwargs): - """Delegate to projects.get_project().""" - return self.projects.get_project(*args, **kwargs) - - def list_projects(self, *args, **kwargs): - """Delegate to projects.list_projects().""" - return self.projects.list_projects(*args, **kwargs) - - def update_project(self, *args, **kwargs): - """Delegate to projects.update_project().""" - return self.projects.update_project(*args, **kwargs) - - def delete_project(self, *args, **kwargs): - """Delegate to projects.delete_project().""" - return self.projects.delete_project(*args, **kwargs) - - def _row_to_project(self, *args, **kwargs): - """Delegate to projects._row_to_project().""" - return self.projects._row_to_project(*args, **kwargs) - - def _calculate_project_progress(self, *args, **kwargs): - """Delegate to projects._calculate_project_progress().""" - return self.projects._calculate_project_progress(*args, **kwargs) - - def get_project_tasks(self, *args, **kwargs): - """Delegate to projects.get_project_tasks().""" - return self.projects.get_project_tasks(*args, **kwargs) - - def get_project_stats(self, *args, **kwargs): - """Delegate to projects.get_project_stats().""" - return self.projects.get_project_stats(*args, **kwargs) - - def get_user_projects(self, *args, **kwargs): - """Delegate to projects.get_user_projects().""" - return self.projects.get_user_projects(*args, **kwargs) - - def user_has_project_access(self, *args, **kwargs): - """Delegate to projects.user_has_project_access().""" - return self.projects.user_has_project_access(*args, **kwargs) - - def create_issue(self, *args, **kwargs): - """Delegate to issues.create_issue().""" - return self.issues.create_issue(*args, **kwargs) - - def get_issue(self, *args, **kwargs): - """Delegate to issues.get_issue().""" - return self.issues.get_issue(*args, **kwargs) - - def get_project_issues(self, *args, **kwargs): - """Delegate to issues.get_project_issues().""" - return self.issues.get_project_issues(*args, **kwargs) - - def get_issues_with_tasks(self, *args, **kwargs): - """Delegate to issues.get_issues_with_tasks().""" - return self.issues.get_issues_with_tasks(*args, **kwargs) - - def list_issues_with_progress(self, *args, **kwargs): - """Delegate to issues.list_issues_with_progress().""" - return self.issues.list_issues_with_progress(*args, **kwargs) - - def get_issue_with_task_counts(self, *args, **kwargs): - """Delegate to issues.get_issue_with_task_counts().""" - return self.issues.get_issue_with_task_counts(*args, **kwargs) - - def _row_to_issue(self, *args, **kwargs): - """Delegate to issues._row_to_issue().""" - return self.issues._row_to_issue(*args, **kwargs) - - def list_issues(self, *args, **kwargs): - """Delegate to issues.list_issues().""" - return self.issues.list_issues(*args, **kwargs) - - def update_issue(self, *args, **kwargs): - """Delegate to issues.update_issue().""" - return self.issues.update_issue(*args, **kwargs) - - def get_issue_completion_status(self, *args, **kwargs): - """Delegate to issues.get_issue_completion_status().""" - return self.issues.get_issue_completion_status(*args, **kwargs) - - def create_task(self, *args, **kwargs): - """Delegate to tasks.create_task().""" - return self.tasks.create_task(*args, **kwargs) - - def get_task(self, *args, **kwargs): - """Delegate to tasks.get_task().""" - return self.tasks.get_task(*args, **kwargs) - - def update_task(self, *args, **kwargs): - """Delegate to tasks.update_task().""" - return self.tasks.update_task(*args, **kwargs) - - def create_task_with_issue(self, *args, **kwargs): - """Delegate to tasks.create_task_with_issue().""" - return self.tasks.create_task_with_issue(*args, **kwargs) - - def get_tasks_by_parent_issue_number(self, *args, **kwargs): - """Delegate to tasks.get_tasks_by_parent_issue_number().""" - return self.tasks.get_tasks_by_parent_issue_number(*args, **kwargs) - - def get_pending_tasks(self, *args, **kwargs): - """Delegate to tasks.get_pending_tasks().""" - return self.tasks.get_pending_tasks(*args, **kwargs) - - def get_tasks_by_issue(self, *args, **kwargs): - """Delegate to tasks.get_tasks_by_issue().""" - return self.tasks.get_tasks_by_issue(*args, **kwargs) - - def add_task_dependency(self, *args, **kwargs): - """Delegate to tasks.add_task_dependency().""" - return self.tasks.add_task_dependency(*args, **kwargs) - - def get_task_dependencies(self, *args, **kwargs): - """Delegate to tasks.get_task_dependencies().""" - return self.tasks.get_task_dependencies(*args, **kwargs) - - def _row_to_task(self, *args, **kwargs): - """Delegate to tasks._row_to_task().""" - return self.tasks._row_to_task(*args, **kwargs) - - def get_dependent_tasks(self, *args, **kwargs): - """Delegate to tasks.get_dependent_tasks().""" - return self.tasks.get_dependent_tasks(*args, **kwargs) - - def remove_task_dependency(self, *args, **kwargs): - """Delegate to tasks.remove_task_dependency().""" - return self.tasks.remove_task_dependency(*args, **kwargs) - - def clear_all_task_dependencies(self, *args, **kwargs): - """Delegate to tasks.clear_all_task_dependencies().""" - return self.tasks.clear_all_task_dependencies(*args, **kwargs) - - def update_task_commit_sha(self, *args, **kwargs): - """Delegate to tasks.update_task_commit_sha().""" - return self.tasks.update_task_commit_sha(*args, **kwargs) - - def get_task_by_commit(self, *args, **kwargs): - """Delegate to tasks.get_task_by_commit().""" - return self.tasks.get_task_by_commit(*args, **kwargs) - - def update_task_intervention_context(self, *args, **kwargs): - """Delegate to tasks.update_task_intervention_context().""" - return self.tasks.update_task_intervention_context(*args, **kwargs) - - def get_task_intervention_context(self, *args, **kwargs): - """Delegate to tasks.get_task_intervention_context().""" - return self.tasks.get_task_intervention_context(*args, **kwargs) - - def clear_task_intervention_context(self, *args, **kwargs): - """Delegate to tasks.clear_task_intervention_context().""" - return self.tasks.clear_task_intervention_context(*args, **kwargs) - - def get_recently_completed_tasks(self, *args, **kwargs): - """Delegate to tasks.get_recently_completed_tasks().""" - return self.tasks.get_recently_completed_tasks(*args, **kwargs) - - def get_tasks_by_agent(self, *args, **kwargs): - """Delegate to tasks.get_tasks_by_agent().""" - return self.tasks.get_tasks_by_agent(*args, **kwargs) - - async def get_tasks_by_agent_async(self, *args, **kwargs): - """Delegate to tasks.get_tasks_by_agent_async().""" - return await self.tasks.get_tasks_by_agent_async(*args, **kwargs) - - def create_agent(self, *args, **kwargs): - """Delegate to agents.create_agent().""" - return self.agents.create_agent(*args, **kwargs) - - def get_agent(self, *args, **kwargs): - """Delegate to agents.get_agent().""" - return self.agents.get_agent(*args, **kwargs) - - def update_agent(self, *args, **kwargs): - """Delegate to agents.update_agent().""" - return self.agents.update_agent(*args, **kwargs) - - def list_agents(self, *args, **kwargs): - """Delegate to agents.list_agents().""" - return self.agents.list_agents(*args, **kwargs) - - def assign_agent_to_project(self, *args, **kwargs): - """Delegate to agents.assign_agent_to_project().""" - return self.agents.assign_agent_to_project(*args, **kwargs) - - def get_agents_for_project(self, *args, **kwargs): - """Delegate to agents.get_agents_for_project().""" - return self.agents.get_agents_for_project(*args, **kwargs) - - def get_projects_for_agent(self, *args, **kwargs): - """Delegate to agents.get_projects_for_agent().""" - return self.agents.get_projects_for_agent(*args, **kwargs) - - def remove_agent_from_project(self, *args, **kwargs): - """Delegate to agents.remove_agent_from_project().""" - return self.agents.remove_agent_from_project(*args, **kwargs) - - def reassign_agent_role(self, *args, **kwargs): - """Delegate to agents.reassign_agent_role().""" - return self.agents.reassign_agent_role(*args, **kwargs) - - def get_agent_assignment(self, *args, **kwargs): - """Delegate to agents.get_agent_assignment().""" - return self.agents.get_agent_assignment(*args, **kwargs) - - def get_available_agents(self, *args, **kwargs): - """Delegate to agents.get_available_agents().""" - return self.agents.get_available_agents(*args, **kwargs) - - def create_blocker(self, *args, **kwargs): - """Delegate to blockers.create_blocker().""" - return self.blockers.create_blocker(*args, **kwargs) - - def get_blocker(self, *args, **kwargs): - """Delegate to blockers.get_blocker().""" - return self.blockers.get_blocker(*args, **kwargs) - - def resolve_blocker(self, *args, **kwargs): - """Delegate to blockers.resolve_blocker().""" - return self.blockers.resolve_blocker(*args, **kwargs) - - def list_blockers(self, *args, **kwargs): - """Delegate to blockers.list_blockers().""" - return self.blockers.list_blockers(*args, **kwargs) - - def get_pending_blocker(self, *args, **kwargs): - """Delegate to blockers.get_pending_blocker().""" - return self.blockers.get_pending_blocker(*args, **kwargs) - - def expire_stale_blockers(self, *args, **kwargs): - """Delegate to blockers.expire_stale_blockers().""" - return self.blockers.expire_stale_blockers(*args, **kwargs) - - def get_blocker_metrics(self, *args, **kwargs): - """Delegate to blockers.get_blocker_metrics().""" - return self.blockers.get_blocker_metrics(*args, **kwargs) - - def create_memory(self, *args, **kwargs): - """Delegate to memories.create_memory().""" - return self.memories.create_memory(*args, **kwargs) - - def upsert_memory(self, *args, **kwargs): - """Delegate to memories.upsert_memory().""" - return self.memories.upsert_memory(*args, **kwargs) - - def get_memory(self, *args, **kwargs): - """Delegate to memories.get_memory().""" - return self.memories.get_memory(*args, **kwargs) - - def get_project_memories(self, *args, **kwargs): - """Delegate to memories.get_project_memories().""" - return self.memories.get_project_memories(*args, **kwargs) - - def get_memories_by_category(self, *args, **kwargs): - """Delegate to memories.get_memories_by_category().""" - return self.memories.get_memories_by_category(*args, **kwargs) - - def get_conversation(self, *args, **kwargs): - """Delegate to memories.get_conversation().""" - return self.memories.get_conversation(*args, **kwargs) - - def create_context_item(self, *args, **kwargs): - """Delegate to context_items.create_context_item().""" - return self.context_items.create_context_item(*args, **kwargs) - - def get_context_item(self, *args, **kwargs): - """Delegate to context_items.get_context_item().""" - return self.context_items.get_context_item(*args, **kwargs) - - def list_context_items(self, *args, **kwargs): - """Delegate to context_items.list_context_items().""" - return self.context_items.list_context_items(*args, **kwargs) - - def update_context_item_tier(self, *args, **kwargs): - """Delegate to context_items.update_context_item_tier().""" - return self.context_items.update_context_item_tier(*args, **kwargs) - - def delete_context_item(self, *args, **kwargs): - """Delegate to context_items.delete_context_item().""" - return self.context_items.delete_context_item(*args, **kwargs) - - def update_context_item_access(self, *args, **kwargs): - """Delegate to context_items.update_context_item_access().""" - return self.context_items.update_context_item_access(*args, **kwargs) - - def archive_cold_items(self, *args, **kwargs): - """Delegate to context_items.archive_cold_items().""" - return self.context_items.archive_cold_items(*args, **kwargs) - - def create_checkpoint(self, *args, **kwargs): - """Delegate to checkpoints.create_checkpoint().""" - return self.checkpoints.create_checkpoint(*args, **kwargs) - - def list_checkpoints(self, *args, **kwargs): - """Delegate to checkpoints.list_checkpoints().""" - return self.checkpoints.list_checkpoints(*args, **kwargs) - - def get_checkpoint(self, *args, **kwargs): - """Delegate to checkpoints.get_checkpoint().""" - return self.checkpoints.get_checkpoint(*args, **kwargs) - - def save_checkpoint(self, *args, **kwargs): - """Delegate to checkpoints.save_checkpoint().""" - return self.checkpoints.save_checkpoint(*args, **kwargs) - - def get_checkpoints(self, *args, **kwargs): - """Delegate to checkpoints.get_checkpoints().""" - return self.checkpoints.get_checkpoints(*args, **kwargs) - - def get_checkpoint_by_id(self, *args, **kwargs): - """Delegate to checkpoints.get_checkpoint_by_id().""" - return self.checkpoints.get_checkpoint_by_id(*args, **kwargs) - - def delete_checkpoint(self, *args, **kwargs): - """Delegate to checkpoints.delete_checkpoint().""" - return self.checkpoints.delete_checkpoint(*args, **kwargs) - - def create_git_branch(self, *args, **kwargs): - """Delegate to git_branches.create_git_branch().""" - return self.git_branches.create_git_branch(*args, **kwargs) - - def get_branch_for_issue(self, *args, **kwargs): - """Delegate to git_branches.get_branch_for_issue().""" - return self.git_branches.get_branch_for_issue(*args, **kwargs) - - def mark_branch_merged(self, *args, **kwargs): - """Delegate to git_branches.mark_branch_merged().""" - return self.git_branches.mark_branch_merged(*args, **kwargs) - - def mark_branch_abandoned(self, *args, **kwargs): - """Delegate to git_branches.mark_branch_abandoned().""" - return self.git_branches.mark_branch_abandoned(*args, **kwargs) - - def get_branch_statistics(self, *args, **kwargs): - """Delegate to git_branches.get_branch_statistics().""" - return self.git_branches.get_branch_statistics(*args, **kwargs) - - def delete_git_branch(self, *args, **kwargs): - """Delegate to git_branches.delete_git_branch().""" - return self.git_branches.delete_git_branch(*args, **kwargs) - - def get_branches_by_status(self, *args, **kwargs): - """Delegate to git_branches.get_branches_by_status().""" - return self.git_branches.get_branches_by_status(*args, **kwargs) - - def get_all_branches_for_issue(self, *args, **kwargs): - """Delegate to git_branches.get_all_branches_for_issue().""" - return self.git_branches.get_all_branches_for_issue(*args, **kwargs) - - def count_branches_for_issue(self, *args, **kwargs): - """Delegate to git_branches.count_branches_for_issue().""" - return self.git_branches.count_branches_for_issue(*args, **kwargs) - - def get_branch_by_name_and_issues(self, *args, **kwargs): - """Delegate to git_branches.get_branch_by_name_and_issues().""" - return self.git_branches.get_branch_by_name_and_issues(*args, **kwargs) - - def create_test_result(self, *args, **kwargs): - """Delegate to test_results.create_test_result().""" - return self.test_results.create_test_result(*args, **kwargs) - - def get_test_results_by_task(self, *args, **kwargs): - """Delegate to test_results.get_test_results_by_task().""" - return self.test_results.get_test_results_by_task(*args, **kwargs) - - def create_lint_result(self, *args, **kwargs): - """Delegate to lint_results.create_lint_result().""" - return self.lint_results.create_lint_result(*args, **kwargs) - - def get_lint_results_for_task(self, *args, **kwargs): - """Delegate to lint_results.get_lint_results_for_task().""" - return self.lint_results.get_lint_results_for_task(*args, **kwargs) - - def get_lint_trend(self, *args, **kwargs): - """Delegate to lint_results.get_lint_trend().""" - return self.lint_results.get_lint_trend(*args, **kwargs) - - def save_code_review(self, *args, **kwargs): - """Delegate to code_reviews.save_code_review().""" - return self.code_reviews.save_code_review(*args, **kwargs) - - def get_code_reviews(self, *args, **kwargs): - """Delegate to code_reviews.get_code_reviews().""" - return self.code_reviews.get_code_reviews(*args, **kwargs) - - def get_code_reviews_by_severity(self, *args, **kwargs): - """Delegate to code_reviews.get_code_reviews_by_severity().""" - return self.code_reviews.get_code_reviews_by_severity(*args, **kwargs) - - def get_code_reviews_by_project(self, *args, **kwargs): - """Delegate to code_reviews.get_code_reviews_by_project().""" - return self.code_reviews.get_code_reviews_by_project(*args, **kwargs) - - def update_quality_gate_status(self, *args, **kwargs): - """Delegate to quality_gates.update_quality_gate_status().""" - return self.quality_gates.update_quality_gate_status(*args, **kwargs) - - def get_quality_gate_status(self, *args, **kwargs): - """Delegate to quality_gates.get_quality_gate_status().""" - return self.quality_gates.get_quality_gate_status(*args, **kwargs) - + # ----- Token usage (dual-use facade) ----- + # Note: ``token_usage`` is the one repository whose backing table is NOT in + # ``SchemaManager`` (control-plane). It is also created by the per-workspace + # schema in ``core/workspace.py``, and ``react_agent``/``stats_commands`` + # instantiate ``Database(workspace.db_path)`` to record token usage via + # ``MetricsTracker``. Keep the delegations alongside the control-plane ones. def save_token_usage(self, *args, **kwargs): """Delegate to token_usage.save_token_usage().""" return self.token_usage.save_token_usage(*args, **kwargs) @@ -755,10 +256,6 @@ def get_token_usage(self, *args, **kwargs): """Delegate to token_usage.get_token_usage().""" return self.token_usage.get_token_usage(*args, **kwargs) - def get_project_costs_aggregate(self, *args, **kwargs): - """Delegate to token_usage.get_project_costs_aggregate().""" - return self.token_usage.get_project_costs_aggregate(*args, **kwargs) - def get_task_token_summary(self, *args, **kwargs): """Delegate to token_usage.get_task_token_summary().""" return self.token_usage.get_task_token_summary(*args, **kwargs) @@ -771,93 +268,7 @@ def get_workspace_token_usage(self, *args, **kwargs): """Delegate to token_usage.get_workspace_token_usage().""" return self.token_usage.get_workspace_token_usage(*args, **kwargs) - def create_correction_attempt(self, *args, **kwargs): - """Delegate to correction_attempts.create_correction_attempt().""" - return self.correction_attempts.create_correction_attempt(*args, **kwargs) - - def get_correction_attempts_by_task(self, *args, **kwargs): - """Delegate to correction_attempts.get_correction_attempts_by_task().""" - return self.correction_attempts.get_correction_attempts_by_task(*args, **kwargs) - - def get_latest_correction_attempt(self, *args, **kwargs): - """Delegate to correction_attempts.get_latest_correction_attempt().""" - return self.correction_attempts.get_latest_correction_attempt(*args, **kwargs) - - def count_correction_attempts(self, *args, **kwargs): - """Delegate to correction_attempts.count_correction_attempts().""" - return self.correction_attempts.count_correction_attempts(*args, **kwargs) - - def get_recent_activity(self, *args, **kwargs): - """Delegate to activities.get_recent_activity().""" - return self.activities.get_recent_activity(*args, **kwargs) - - def get_prd(self, *args, **kwargs): - """Delegate to activities.get_prd().""" - return self.activities.get_prd(*args, **kwargs) - - def delete_prd(self, *args, **kwargs): - """Delegate to activities.delete_prd().""" - return self.activities.delete_prd(*args, **kwargs) - - def delete_discovery_answers(self, *args, **kwargs): - """Delegate to activities.delete_discovery_answers().""" - return self.activities.delete_discovery_answers(*args, **kwargs) - - def delete_project_tasks_and_issues(self, project_id: int) -> dict: - """Delete all tasks and issues for a project atomically. - - Performs cascading delete in a single transaction: - 1. Deletes task dependencies, test results, correction attempts - 2. Deletes tasks (code_reviews and task_evidence cascade automatically) - 3. Deletes issues - - This method delegates to TaskRepository and IssueRepository for proper - separation of concerns and handles all FK constraints correctly. - - Args: - project_id: Project ID - - Returns: - Dictionary with counts: {"tasks": int, "issues": int} - """ - with self._sync_lock: - cursor = self.conn.cursor() - - try: - # Count before deletion for return value - cursor.execute( - "SELECT COUNT(*) FROM tasks WHERE project_id = ?", - (project_id,), - ) - task_count = cursor.fetchone()[0] - - cursor.execute( - "SELECT COUNT(*) FROM issues WHERE project_id = ?", - (project_id,), - ) - issue_count = cursor.fetchone()[0] - - # Delete tasks first with all FK dependencies (single transaction) - # Pass cursor to avoid intermediate commits - self.tasks.delete_all_project_tasks(project_id, cursor=cursor) - - # Then delete issues - self.issues.delete_all_project_issues(project_id, cursor=cursor) - - # Commit the entire operation atomically - self.conn.commit() - return {"tasks": task_count, "issues": issue_count} - - except Exception: - self.conn.rollback() - raise - + # ----- Audit log ----- def create_audit_log(self, *args, **kwargs): """Delegate to audit_logs.create_audit_log().""" return self.audit_logs.create_audit_log(*args, **kwargs) - - async def cleanup_expired_sessions(self, *args, **kwargs): - """Delegate to projects.cleanup_expired_sessions().""" - return await self.projects.cleanup_expired_sessions(*args, **kwargs) - - # End of delegated methods diff --git a/codeframe/persistence/repositories/__init__.py b/codeframe/persistence/repositories/__init__.py index be5f1d5b..894c342f 100644 --- a/codeframe/persistence/repositories/__init__.py +++ b/codeframe/persistence/repositories/__init__.py @@ -1,49 +1,20 @@ -"""Domain-specific repository exports. +"""Control-plane repository exports. -Extracted from monolithic Database class for better maintainability. -Each repository handles operations for a specific domain. +Only the repositories backing the global control-plane store survive: API keys, +audit logs, and token usage (plus the shared base). The v1 domain repositories +(projects/issues/tasks/agents/...) were removed with the v2 cleanup — that data +now lives per-workspace via ``codeframe.core.workspace``. The interactive-session +repository is imported directly from its module by ``database.py``. """ from codeframe.persistence.repositories.base import BaseRepository -from codeframe.persistence.repositories.project_repository import ProjectRepository -from codeframe.persistence.repositories.issue_repository import IssueRepository -from codeframe.persistence.repositories.task_repository import TaskRepository -from codeframe.persistence.repositories.agent_repository import AgentRepository -from codeframe.persistence.repositories.blocker_repository import BlockerRepository -from codeframe.persistence.repositories.memory_repository import MemoryRepository -from codeframe.persistence.repositories.context_repository import ContextRepository -from codeframe.persistence.repositories.checkpoint_repository import CheckpointRepository -from codeframe.persistence.repositories.git_repository import GitRepository -from codeframe.persistence.repositories.test_repository import TestRepository -from codeframe.persistence.repositories.lint_repository import LintRepository -from codeframe.persistence.repositories.review_repository import ReviewRepository -from codeframe.persistence.repositories.quality_repository import QualityRepository from codeframe.persistence.repositories.token_repository import TokenRepository -from codeframe.persistence.repositories.correction_repository import CorrectionRepository -from codeframe.persistence.repositories.activity_repository import ActivityRepository from codeframe.persistence.repositories.audit_repository import AuditRepository -from codeframe.persistence.repositories.pr_repository import PRRepository from codeframe.persistence.repositories.api_key_repository import APIKeyRepository __all__ = [ "BaseRepository", - "ProjectRepository", - "IssueRepository", - "TaskRepository", - "AgentRepository", - "BlockerRepository", - "MemoryRepository", - "ContextRepository", - "CheckpointRepository", - "GitRepository", - "TestRepository", - "LintRepository", - "ReviewRepository", - "QualityRepository", "TokenRepository", - "CorrectionRepository", - "ActivityRepository", "AuditRepository", - "PRRepository", "APIKeyRepository", ] diff --git a/codeframe/persistence/repositories/activity_repository.py b/codeframe/persistence/repositories/activity_repository.py deleted file mode 100644 index b65215db..00000000 --- a/codeframe/persistence/repositories/activity_repository.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Repository for Activity Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from typing import List, Optional, Dict, Any -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class ActivityRepository(BaseRepository): - """Repository for activity repository operations.""" - - - def get_recent_activity(self, project_id: int, limit: int = 50) -> List[Dict[str, Any]]: - """ - Get recent activity/changelog entries for a project. - - Args: - project_id: Project ID to filter activity - limit: Maximum number of activity items to return - - Returns: - List of activity dictionaries formatted for frontend - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT - timestamp, - agent_id, - action, - task_id, - details - FROM changelog - WHERE project_id = ? - ORDER BY timestamp DESC - LIMIT ? - """, - (project_id, limit), - ) - - columns = [desc[0] for desc in cursor.description] - rows = cursor.fetchall() - - # Format for frontend - activity_items = [] - for row in rows: - activity_dict = dict(zip(columns, row)) - - # Map database fields to frontend expected format - activity_items.append( - { - "timestamp": activity_dict["timestamp"], - "type": activity_dict["action"], - "agent": activity_dict["agent_id"] or "system", - "message": activity_dict.get("details") or activity_dict["action"], - } - ) - - return activity_items - - # Context Management Methods (007-context-management) - - - def get_prd(self, project_id: int) -> Optional[Dict[str, Any]]: - """Get PRD for a project. - - Args: - project_id: Project ID - - Returns: - Dictionary with prd_content, generated_at, updated_at or None if not found - """ - - cursor = self.conn.cursor() - - # Get PRD content - cursor.execute( - """ - SELECT value, created_at, updated_at - FROM memory - WHERE project_id = ? AND category = 'prd' AND key = 'content' - """, - (project_id,), - ) - prd_row = cursor.fetchone() - - if not prd_row: - return None - - # Get generated_at timestamp - cursor.execute( - """ - SELECT value - FROM memory - WHERE project_id = ? AND category = 'prd' AND key = 'generated_at' - """, - (project_id,), - ) - generated_row = cursor.fetchone() - - # Determine generated_at - generated_at = ( - generated_row["value"] if generated_row else self._ensure_rfc3339(prd_row["created_at"]) - ) - - # Determine updated_at - use generated_at if updated_at is same as created_at - updated_at = self._ensure_rfc3339( - prd_row["updated_at"] if prd_row["updated_at"] else prd_row["created_at"] - ) - - # If updated_at == created_at (never been updated), use generated_at for both - if prd_row["updated_at"] == prd_row["created_at"] and generated_row: - updated_at = generated_at - - return { - "prd_content": prd_row["value"], - "generated_at": generated_at, - "updated_at": updated_at, - } - - def delete_prd(self, project_id: int) -> bool: - """Delete PRD content for a project. - - Removes all PRD-related entries from the memory table. - - Args: - project_id: Project ID - - Returns: - True if PRD existed and was deleted, False if no PRD existed - """ - cursor = self.conn.cursor() - - # Check if PRD exists first - cursor.execute( - "SELECT COUNT(*) FROM memory WHERE project_id = ? AND category = 'prd'", - (project_id,), - ) - count = cursor.fetchone()[0] - - if count == 0: - return False - - # Delete all PRD entries - cursor.execute( - "DELETE FROM memory WHERE project_id = ? AND category = 'prd'", - (project_id,), - ) - self.conn.commit() - - logger.info(f"Deleted PRD for project {project_id}") - return True - - def delete_discovery_answers(self, project_id: int) -> int: - """Delete all discovery answers for a project. - - Removes all discovery_answers entries from the memory table. - - Args: - project_id: Project ID - - Returns: - Number of answers deleted - """ - cursor = self.conn.cursor() - - # Count existing answers - cursor.execute( - "SELECT COUNT(*) FROM memory WHERE project_id = ? AND category = 'discovery_answers'", - (project_id,), - ) - count = cursor.fetchone()[0] - - if count == 0: - return 0 - - # Delete all discovery answers - cursor.execute( - "DELETE FROM memory WHERE project_id = ? AND category = 'discovery_answers'", - (project_id,), - ) - self.conn.commit() - - logger.info(f"Deleted {count} discovery answers for project {project_id}") - return count - - # Issues/Tasks methods (cf-26) diff --git a/codeframe/persistence/repositories/agent_repository.py b/codeframe/persistence/repositories/agent_repository.py deleted file mode 100644 index 392b8f5d..00000000 --- a/codeframe/persistence/repositories/agent_repository.py +++ /dev/null @@ -1,384 +0,0 @@ -"""Repository for Agent Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from typing import List, Optional, Dict, Any -import logging - - -from codeframe.core.models import ( - AgentMaturity, -) -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class AgentRepository(BaseRepository): - """Repository for agent repository operations.""" - - - def create_agent( - self, - agent_id: str, - agent_type: str, - provider: str, - maturity_level: AgentMaturity, - ) -> str: - """Create a new agent. - - Args: - agent_id: Unique agent identifier - agent_type: Type of agent (lead, backend, frontend, test, review) - provider: AI provider (claude, gpt4) - maturity_level: Maturity level (D1-D4) - - Returns: - Agent ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO agents (id, type, provider, maturity_level, status) - VALUES (?, ?, ?, ?, ?) - """, - (agent_id, agent_type, provider, maturity_level.value, "idle"), - ) - self.conn.commit() - return agent_id - - - - def get_agent(self, agent_id: str) -> Optional[Dict[str, Any]]: - """Get agent by ID. - - Args: - agent_id: Agent ID - - Returns: - Agent dictionary or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM agents WHERE id = ?", (agent_id,)) - row = cursor.fetchone() - return dict(row) if row else None - - - - # Whitelist of allowed agent fields for updates (prevents SQL injection) - ALLOWED_AGENT_FIELDS = { - "type", - "project_id", - "provider", - "maturity_level", - "status", - "current_task_id", - "last_heartbeat", - "metrics", - } - - def update_agent(self, agent_id: str, updates: Dict[str, Any]) -> int: - """Update agent fields. - - Args: - agent_id: Agent ID to update - updates: Dictionary of fields to update - - Returns: - Number of rows affected - - Raises: - ValueError: If any update key is not in the allowed fields whitelist - """ - if not updates: - return 0 - - # Validate all keys against whitelist to prevent SQL injection - invalid_fields = set(updates.keys()) - self.ALLOWED_AGENT_FIELDS - if invalid_fields: - raise ValueError( - f"Invalid agent fields: {invalid_fields}. " - f"Allowed fields: {self.ALLOWED_AGENT_FIELDS}" - ) - - fields = [] - values = [] - for key, value in updates.items(): - # Safe to use key here since it's been validated against whitelist - fields.append(f"{key} = ?") - # Handle enum values - if isinstance(value, AgentMaturity): - values.append(value.value) - else: - values.append(value) - - values.append(agent_id) - - query = f"UPDATE agents SET {', '.join(fields)} WHERE id = ?" - - cursor = self.conn.cursor() - cursor.execute(query, values) - self.conn.commit() - - return cursor.rowcount - - - - def list_agents(self) -> List[Dict[str, Any]]: - """List all agents. - - Returns: - List of agent dictionaries - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM agents ORDER BY id") - rows = cursor.fetchall() - return [dict(row) for row in rows] - - - - def assign_agent_to_project(self, project_id: int, agent_id: str, role: str = "worker") -> int: - """Assign an agent to a project. - - Args: - project_id: Project ID - agent_id: Agent ID - role: Agent's role in this project - - Returns: - Assignment ID - - Raises: - sqlite3.IntegrityError: If agent already assigned to project (while active) - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO project_agents (project_id, agent_id, role, is_active) - VALUES (?, ?, ?, TRUE) - """, - (project_id, agent_id, role), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_agents_for_project( - self, project_id: int, active_only: bool = True - ) -> List[Dict[str, Any]]: - """Get all agents assigned to a project. - - Args: - project_id: Project ID - active_only: If True, only return currently assigned agents - - Returns: - List of agent dictionaries with assignment metadata - """ - cursor = self.conn.cursor() - - query = """ - SELECT - a.id AS agent_id, - a.type, - a.provider, - a.maturity_level, - a.status, - a.current_task_id, - a.last_heartbeat, - a.metrics, - pa.id AS assignment_id, - pa.role, - pa.assigned_at, - pa.unassigned_at, - pa.is_active - FROM agents a - JOIN project_agents pa ON a.id = pa.agent_id - WHERE pa.project_id = ? - """ - - if active_only: - query += " AND pa.is_active = TRUE" - - query += " ORDER BY pa.assigned_at DESC" - - cursor.execute(query, (project_id,)) - return [dict(row) for row in cursor.fetchall()] - - - - def get_projects_for_agent( - self, agent_id: str, active_only: bool = True - ) -> List[Dict[str, Any]]: - """Get all projects an agent is assigned to. - - Args: - agent_id: Agent ID - active_only: If True, only return active assignments - - Returns: - List of project dictionaries with assignment metadata - """ - cursor = self.conn.cursor() - - query = """ - SELECT - p.id AS project_id, - p.name, - p.description, - p.status, - p.phase, - pa.role, - pa.assigned_at, - pa.unassigned_at, - pa.is_active - FROM projects p - JOIN project_agents pa ON p.id = pa.project_id - WHERE pa.agent_id = ? - """ - - if active_only: - query += " AND pa.is_active = TRUE" - - query += " ORDER BY pa.assigned_at DESC" - - cursor.execute(query, (agent_id,)) - return [dict(row) for row in cursor.fetchall()] - - - - def remove_agent_from_project(self, project_id: int, agent_id: str) -> int: - """Remove an agent from a project (soft delete). - - Args: - project_id: Project ID - agent_id: Agent ID - - Returns: - Number of rows affected (0 if not assigned, 1 if unassigned) - """ - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE project_agents - SET is_active = FALSE, - unassigned_at = CURRENT_TIMESTAMP - WHERE project_id = ? - AND agent_id = ? - AND is_active = TRUE - """, - (project_id, agent_id), - ) - self.conn.commit() - return cursor.rowcount - - - - def reassign_agent_role(self, project_id: int, agent_id: str, new_role: str) -> int: - """Update an agent's role on a project. - - Args: - project_id: Project ID - agent_id: Agent ID - new_role: New role for the agent - - Returns: - Number of rows affected - """ - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE project_agents - SET role = ? - WHERE project_id = ? - AND agent_id = ? - AND is_active = TRUE - """, - (new_role, project_id, agent_id), - ) - self.conn.commit() - return cursor.rowcount - - - - def get_agent_assignment(self, project_id: int, agent_id: str) -> Optional[Dict[str, Any]]: - """Get assignment details for a specific agent-project pair. - - Args: - project_id: Project ID - agent_id: Agent ID - - Returns: - Assignment dictionary or None if not found - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT - id, - project_id, - agent_id, - role, - assigned_at, - unassigned_at, - is_active - FROM project_agents - WHERE project_id = ? AND agent_id = ? - ORDER BY id DESC - LIMIT 1 - """, - (project_id, agent_id), - ) - row = cursor.fetchone() - return dict(row) if row else None - - - - def get_available_agents( - self, agent_type: Optional[str] = None, exclude_project_id: Optional[int] = None - ) -> List[Dict[str, Any]]: - """Get agents available for assignment (not at capacity). - - Args: - agent_type: Filter by agent type (optional) - exclude_project_id: Exclude agents already on this project - - Returns: - List of available agent dictionaries - """ - cursor = self.conn.cursor() - - query = """ - SELECT - a.*, - COUNT(pa.id) AS active_assignments - FROM agents a - LEFT JOIN project_agents pa ON a.id = pa.agent_id - AND pa.is_active = TRUE - """ - - params = [] - conditions = [] - - if exclude_project_id: - conditions.append("(pa.project_id IS NULL OR pa.project_id != ?)") - params.append(exclude_project_id) - - if agent_type: - conditions.append("a.type = ?") - params.append(agent_type) - - if conditions: - query += " WHERE " + " AND ".join(conditions) - - query += """ - GROUP BY a.id - HAVING active_assignments < 3 - ORDER BY active_assignments ASC, a.last_heartbeat DESC - """ - - cursor.execute(query, params) - return [dict(row) for row in cursor.fetchall()] - diff --git a/codeframe/persistence/repositories/base.py b/codeframe/persistence/repositories/base.py index bbecb610..e6936eba 100644 --- a/codeframe/persistence/repositories/base.py +++ b/codeframe/persistence/repositories/base.py @@ -244,44 +244,6 @@ def _format_datetime(self, dt: Optional[datetime]) -> Optional[str]: return None return dt.isoformat() - def _get_last_insert_id(self) -> int: - """Get the last inserted row ID with thread-safe locking. - - Returns: - Last row ID - - Raises: - RuntimeError: If sync connection is not available - - Note: - Uses threading lock if available to ensure thread-safe access - to the shared connection object. - """ - if self.conn is None: - raise RuntimeError("Sync connection not available, use async methods") - - if self._sync_lock is not None: - with self._sync_lock: - cursor = self.conn.cursor() - return cursor.lastrowid - else: - cursor = self.conn.cursor() - return cursor.lastrowid - - async def _get_last_insert_id_async(self) -> int: - """Get the last inserted row ID asynchronously. - - Returns: - Last row ID - - Raises: - RuntimeError: If async connection is not available - """ - if self._async_conn is None: - raise RuntimeError("Async connection not available, use sync methods") - cursor = await self._async_conn.cursor() - return cursor.lastrowid - async def _get_async_conn(self) -> aiosqlite.Connection: """Get async connection with health check and automatic reconnection. diff --git a/codeframe/persistence/repositories/blocker_repository.py b/codeframe/persistence/repositories/blocker_repository.py deleted file mode 100644 index 718d0050..00000000 --- a/codeframe/persistence/repositories/blocker_repository.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Repository for Blocker Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from datetime import datetime, UTC -from typing import List, Optional, Dict, Any -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class BlockerRepository(BaseRepository): - """Repository for blocker repository operations.""" - - - def create_blocker( - self, - agent_id: str, - project_id: int, - task_id: Optional[int], - blocker_type: str, - question: str, - ) -> int: - """Create a new blocker with rate limiting. - - Rate limit: 10 blockers per minute per agent (T063). - - Args: - agent_id: ID of the agent creating the blocker - project_id: ID of the project this blocker belongs to - task_id: Associated task ID (nullable for agent-level blockers) - blocker_type: Type of blocker ('SYNC' or 'ASYNC') - question: Question for the user (max 2000 chars) - - Returns: - Blocker ID of the created blocker - - Raises: - ValueError: If agent exceeds rate limit (10 blockers/minute) - """ - cursor = self.conn.cursor() - - # Check rate limit: 10 blockers per minute per agent - cursor.execute( - """SELECT COUNT(*) as count - FROM blockers - WHERE agent_id = ? - AND datetime(created_at) > datetime('now', '-60 seconds')""", - (agent_id,), - ) - row = cursor.fetchone() - recent_blocker_count = row["count"] - - if recent_blocker_count >= 10: - raise ValueError( - f"Rate limit exceeded: Agent {agent_id} has created {recent_blocker_count} " - f"blockers in the last minute (limit: 10/minute)" - ) - - # Create the blocker - cursor.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status) - VALUES (?, ?, ?, ?, ?, 'PENDING')""", - (agent_id, project_id, task_id, blocker_type, question), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_blocker(self, blocker_id: int) -> Optional[Dict[str, Any]]: - """Get blocker details by ID. - - Args: - blocker_id: ID of the blocker - - Returns: - Blocker dictionary or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM blockers WHERE id = ?", (blocker_id,)) - row = cursor.fetchone() - return dict(row) if row else None - - - - def resolve_blocker(self, blocker_id: int, answer: str) -> bool: - """Resolve a blocker with user's answer. - - Args: - blocker_id: ID of the blocker to resolve - answer: User's answer (max 5000 chars) - - Returns: - True if blocker was resolved, False if already resolved or not found - """ - cursor = self.conn.cursor() - resolved_at = datetime.now(UTC).isoformat() - cursor.execute( - """UPDATE blockers - SET answer = ?, status = 'RESOLVED', resolved_at = ? - WHERE id = ? AND status = 'PENDING'""", - (answer, resolved_at, blocker_id), - ) - self.conn.commit() - return cursor.rowcount > 0 - - - - def list_blockers(self, project_id: int, status: Optional[str] = None) -> Dict[str, Any]: - """List blockers with agent/task info joined. - - Args: - project_id: Filter by project ID - status: Optional status filter ('PENDING', 'RESOLVED', 'EXPIRED') - - Returns: - Dictionary with blockers list and counts: - - blockers: List of blocker dictionaries with enriched data - - total: Total number of blockers - - pending_count: Number of pending blockers - - sync_count: Number of SYNC blockers - - async_count: Number of ASYNC blockers - """ - cursor = self.conn.cursor() - - # Build query with optional status filter - query = """ - SELECT - b.*, - a.type as agent_name, - t.title as task_title, - (julianday('now') - julianday(b.created_at)) * 86400000 as time_waiting_ms - FROM blockers b - LEFT JOIN agents a ON b.agent_id = a.id - LEFT JOIN tasks t ON b.task_id = t.id - WHERE b.project_id = ? - """ - params = [project_id] - - if status: - query += " AND b.status = ?" - params.append(status) - - query += " ORDER BY b.created_at DESC" - - cursor.execute(query, params) - rows = cursor.fetchall() - - blockers = [dict(row) for row in rows] - pending_count = sum(1 for b in blockers if b.get("status") == "PENDING") - sync_count = sum(1 for b in blockers if b.get("blocker_type") == "SYNC") - async_count = sum(1 for b in blockers if b.get("blocker_type") == "ASYNC") - - return { - "blockers": blockers, - "total": len(blockers), - "pending_count": pending_count, - "sync_count": sync_count, - "async_count": async_count, - } - - - - def get_pending_blocker(self, agent_id: str) -> Optional[Dict[str, Any]]: - """Get oldest pending blocker for an agent. - - Args: - agent_id: ID of the agent - - Returns: - Blocker dictionary or None if no pending blockers - """ - cursor = self.conn.cursor() - cursor.execute( - """SELECT * FROM blockers - WHERE agent_id = ? AND status = 'PENDING' - ORDER BY created_at ASC LIMIT 1""", - (agent_id,), - ) - row = cursor.fetchone() - return dict(row) if row else None - - - - def expire_stale_blockers(self, hours: int = 24) -> List[int]: - """Expire blockers pending longer than specified hours. - - Args: - hours: Number of hours before blocker is considered stale (default: 24) - - Returns: - List of expired blocker IDs - """ - cursor = self.conn.cursor() - cursor.execute( - """UPDATE blockers - SET status = 'EXPIRED' - WHERE status = 'PENDING' - AND datetime(created_at) < datetime('now', ? || ' hours') - RETURNING id""", - (f"-{hours}",) - ) - # Fetch results BEFORE commit (SQLite requirement for RETURNING clause) - expired_ids = [row[0] for row in cursor.fetchall()] - self.conn.commit() - return expired_ids - - - - def get_blocker_metrics(self, project_id: int) -> Dict[str, Any]: - """Calculate blocker metrics for a project. - - Tracks: - - Average resolution time (seconds from created_at to resolved_at for RESOLVED blockers) - - Expiration rate (percentage of blockers that expired vs resolved) - - Total blocker counts by status and type - - Args: - project_id: Project ID to calculate metrics for - - Returns: - Dictionary with metrics: - - avg_resolution_time_seconds: Average time to resolve (None if no resolved blockers) - - expiration_rate_percent: Percentage of blockers that expired (0-100) - - total_blockers: Total count of all blockers - - resolved_count: Count of RESOLVED blockers - - expired_count: Count of EXPIRED blockers - - pending_count: Count of PENDING blockers - - sync_count: Count of SYNC blockers - - async_count: Count of ASYNC blockers - """ - cursor = self.conn.cursor() - - # Get all blockers for tasks in this project - cursor.execute( - """ - SELECT - b.status, - b.blocker_type, - b.created_at, - b.resolved_at - FROM blockers b - INNER JOIN tasks t ON b.task_id = t.id - WHERE t.project_id = ? - """, - (project_id,), - ) - - rows = cursor.fetchall() - - if not rows: - return { - "avg_resolution_time_seconds": None, - "expiration_rate_percent": 0.0, - "total_blockers": 0, - "resolved_count": 0, - "expired_count": 0, - "pending_count": 0, - "sync_count": 0, - "async_count": 0, - } - - # Calculate metrics - total_blockers = len(rows) - resolved_count = 0 - expired_count = 0 - pending_count = 0 - sync_count = 0 - async_count = 0 - resolution_times = [] - - for row in rows: - status = row["status"] - blocker_type = row["blocker_type"] - created_at = row["created_at"] - resolved_at = row["resolved_at"] - - # Count by status - if status == "RESOLVED": - resolved_count += 1 - # Calculate resolution time - if created_at and resolved_at: - created = datetime.fromisoformat(created_at) - resolved = datetime.fromisoformat(resolved_at) - - # Normalize both to timezone-aware (assume UTC if naive) - if created.tzinfo is None: - created = created.replace(tzinfo=UTC) - if resolved.tzinfo is None: - resolved = resolved.replace(tzinfo=UTC) - - resolution_time_seconds = (resolved - created).total_seconds() - resolution_times.append(resolution_time_seconds) - elif status == "EXPIRED": - expired_count += 1 - elif status == "PENDING": - pending_count += 1 - - # Count by type - if blocker_type == "SYNC": - sync_count += 1 - elif blocker_type == "ASYNC": - async_count += 1 - - # Calculate average resolution time - avg_resolution_time = None - if resolution_times: - avg_resolution_time = sum(resolution_times) / len(resolution_times) - - # Calculate expiration rate - completed_blockers = resolved_count + expired_count - expiration_rate = 0.0 - if completed_blockers > 0: - expiration_rate = (expired_count / completed_blockers) * 100.0 - - return { - "avg_resolution_time_seconds": avg_resolution_time, - "expiration_rate_percent": expiration_rate, - "total_blockers": total_blockers, - "resolved_count": resolved_count, - "expired_count": expired_count, - "pending_count": pending_count, - "sync_count": sync_count, - "async_count": async_count, - } - diff --git a/codeframe/persistence/repositories/checkpoint_repository.py b/codeframe/persistence/repositories/checkpoint_repository.py deleted file mode 100644 index 184d3c7d..00000000 --- a/codeframe/persistence/repositories/checkpoint_repository.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Repository for Checkpoint Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -import json -from datetime import datetime, timezone -from typing import List, Optional, Dict, Any, TYPE_CHECKING -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -if TYPE_CHECKING: - from codeframe.core.models import Checkpoint, CheckpointMetadata - -logger = logging.getLogger(__name__) - - -class CheckpointRepository(BaseRepository): - """Repository for checkpoint repository operations.""" - - - def create_checkpoint( - self, - agent_id: str, - checkpoint_data: str, - items_count: int, - items_archived: int, - hot_items_retained: int, - token_count: int, - ) -> int: - """Create a flash save checkpoint. - - Args: - agent_id: Agent ID creating the checkpoint - checkpoint_data: JSON serialized context state - items_count: Total items before flash save - items_archived: Number of COLD items archived - hot_items_retained: Number of HOT items kept - token_count: Total tokens before flash save - - Returns: - Created checkpoint ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO context_checkpoints ( - agent_id, checkpoint_data, items_count, items_archived, - hot_items_retained, token_count - ) VALUES (?, ?, ?, ?, ?, ?) - """, - ( - agent_id, - checkpoint_data, - items_count, - items_archived, - hot_items_retained, - token_count, - ), - ) - self.conn.commit() - return cursor.lastrowid - - - - def list_checkpoints(self, agent_id: str, limit: int = 10) -> List[Dict[str, Any]]: - """List checkpoints for an agent, most recent first. - - Args: - agent_id: Agent ID to filter by - limit: Maximum number of checkpoints to return - - Returns: - List of checkpoint dictionaries ordered by created_at DESC - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT * FROM context_checkpoints - WHERE agent_id = ? - ORDER BY created_at DESC - LIMIT ? - """, - (agent_id, limit), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - - - def get_checkpoint(self, checkpoint_id: int) -> Optional[Dict[str, Any]]: - """Get a checkpoint by ID. - - Args: - checkpoint_id: Checkpoint ID - - Returns: - Checkpoint dictionary or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM context_checkpoints WHERE id = ?", (checkpoint_id,)) - row = cursor.fetchone() - return dict(row) if row else None - - # Sprint 9: MVP Completion Database Methods - - - - def save_checkpoint( - self, - project_id: int, - name: str, - description: Optional[str], - trigger: str, - git_commit: str, - database_backup_path: str, - context_snapshot_path: str, - metadata: "CheckpointMetadata", - ) -> int: - """Save a checkpoint to database. - - Args: - project_id: Project ID - name: Checkpoint name (max 100 chars) - description: Optional description (max 500 chars) - trigger: Trigger type (manual, auto, phase_transition, pause) - git_commit: Git commit SHA - database_backup_path: Path to database backup file - context_snapshot_path: Path to context snapshot JSON - metadata: CheckpointMetadata object - - Returns: - Created checkpoint ID - """ - - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO checkpoints ( - project_id, name, description, trigger, git_commit, - database_backup_path, context_snapshot_path, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - project_id, - name, - description, - trigger, - git_commit, - database_backup_path, - context_snapshot_path, - json.dumps(metadata.model_dump()), - ), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_checkpoints(self, project_id: int) -> List["Checkpoint"]: - """Get all checkpoints for a project, sorted by created_at DESC. - - Args: - project_id: Project ID - - Returns: - List of Checkpoint objects, most recent first - """ - from codeframe.core.models import Checkpoint, CheckpointMetadata - - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT - id, project_id, name, description, trigger, git_commit, - database_backup_path, context_snapshot_path, metadata, created_at - FROM checkpoints - WHERE project_id = ? - ORDER BY created_at DESC, id DESC - """, - (project_id,), - ) - - checkpoints = [] - for row in cursor.fetchall(): - # Parse metadata JSON - metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {} - metadata = CheckpointMetadata(**metadata_dict) - - checkpoint = Checkpoint( - id=row["id"], - project_id=row["project_id"], - name=row["name"], - description=row["description"], - trigger=row["trigger"], - git_commit=row["git_commit"], - database_backup_path=row["database_backup_path"], - context_snapshot_path=row["context_snapshot_path"], - metadata=metadata, - created_at=( - datetime.fromisoformat(row["created_at"]) - if row["created_at"] - else datetime.now(timezone.utc) - ), - ) - checkpoints.append(checkpoint) - - return checkpoints - - - - def get_checkpoint_by_id(self, checkpoint_id: int) -> Optional["Checkpoint"]: - """Get a checkpoint by ID. - - Args: - checkpoint_id: Checkpoint ID - - Returns: - Checkpoint object or None if not found - """ - from codeframe.core.models import Checkpoint, CheckpointMetadata - - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT - id, project_id, name, description, trigger, git_commit, - database_backup_path, context_snapshot_path, metadata, created_at - FROM checkpoints - WHERE id = ? - """, - (checkpoint_id,), - ) - - row = cursor.fetchone() - if not row: - return None - - # Parse metadata JSON - metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {} - metadata = CheckpointMetadata(**metadata_dict) - - return Checkpoint( - id=row["id"], - project_id=row["project_id"], - name=row["name"], - description=row["description"], - trigger=row["trigger"], - git_commit=row["git_commit"], - database_backup_path=row["database_backup_path"], - context_snapshot_path=row["context_snapshot_path"], - metadata=metadata, - created_at=( - datetime.fromisoformat(row["created_at"]) - if row["created_at"] - else datetime.now(timezone.utc) - ), - ) - - - - def delete_checkpoint(self, checkpoint_id: int) -> None: - """Delete a checkpoint from the database. - - Args: - checkpoint_id: Checkpoint ID to delete - """ - cursor = self.conn.cursor() - cursor.execute("DELETE FROM checkpoints WHERE id = ?", (checkpoint_id,)) - self.conn.commit() - - # ============================================================================ - # Token Usage and Metrics Methods (Sprint 10 Phase 5) - # ============================================================================ - diff --git a/codeframe/persistence/repositories/context_repository.py b/codeframe/persistence/repositories/context_repository.py deleted file mode 100644 index eded7151..00000000 --- a/codeframe/persistence/repositories/context_repository.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Repository for Context Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from datetime import datetime -from typing import List, Optional, Dict, Any -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class ContextRepository(BaseRepository): - """Repository for context repository operations.""" - - - def create_context_item( - self, project_id: int, agent_id: str, item_type: str, content: str - ) -> str: - """Create a new context item with auto-calculated importance score. - - Auto-calculates importance score using hybrid exponential decay algorithm: - - Type weight (40%): Based on item_type - - Age decay (40%): Exponential decay (new items get 1.0) - - Access boost (20%): Log-normalized frequency (new items get 0.0) - - Args: - project_id: Project ID this context belongs to - agent_id: Agent ID that created this context - item_type: Type of context (TASK, CODE, ERROR, TEST_RESULT, PRD_SECTION) - content: The actual context content - - Returns: - Created context item ID (UUID string) - """ - import uuid - from datetime import UTC - from codeframe.lib.importance_scorer import calculate_importance_score, assign_tier - - # Auto-calculate importance score for new item - created_at = datetime.now(UTC) - importance_score = calculate_importance_score( - item_type=item_type, - created_at=created_at, - access_count=0, # New item has no accesses yet - last_accessed=created_at, - ) - - # Auto-assign tier based on importance score (T040) - # Convert to lowercase for current_tier column - tier = assign_tier(importance_score).lower() - - # Generate UUID for id (actual schema uses TEXT PRIMARY KEY) - item_id = str(uuid.uuid4()) - - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO context_items ( - id, project_id, agent_id, item_type, content, importance_score, - current_tier, created_at, last_accessed, access_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - item_id, - project_id, - agent_id, - item_type, - content, - importance_score, - tier, - created_at.isoformat(), - created_at.isoformat(), - 0, - ), - ) - self.conn.commit() - return item_id - - - - def get_context_item(self, item_id: str) -> Optional[Dict[str, Any]]: - """Get a context item by ID. - - Args: - item_id: Context item ID (UUID string) - - Returns: - Context item dictionary or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM context_items WHERE id = ?", (item_id,)) - row = cursor.fetchone() - return dict(row) if row else None - - - - def list_context_items( - self, - project_id: int, - agent_id: str, - tier: Optional[str] = None, - limit: int = 100, - offset: int = 0, - ) -> List[Dict[str, Any]]: - """List context items for an agent on a project, optionally filtered by tier. - - Args: - project_id: Project ID to filter by - agent_id: Agent ID to filter by - tier: Optional tier filter (HOT, WARM, COLD) - limit: Maximum number of items to return - offset: Number of items to skip - - Returns: - List of context item dictionaries - """ - cursor = self.conn.cursor() - - if tier: - # Convert tier to lowercase for current_tier column - tier_lower = tier.lower() - query = """ - SELECT * FROM context_items - WHERE project_id = ? AND agent_id = ? AND current_tier = ? - ORDER BY importance_score DESC, last_accessed DESC - LIMIT ? OFFSET ? - """ - cursor.execute(query, (project_id, agent_id, tier_lower, limit, offset)) - else: - query = """ - SELECT * FROM context_items - WHERE project_id = ? AND agent_id = ? - ORDER BY importance_score DESC, last_accessed DESC - LIMIT ? OFFSET ? - """ - cursor.execute(query, (project_id, agent_id, limit, offset)) - - rows = cursor.fetchall() - return [dict(row) for row in rows] - - - - def update_context_item_tier(self, item_id: str, tier: str, importance_score: float) -> None: - """Update a context item's tier and importance score. - - Args: - item_id: Context item ID (UUID string) - tier: New tier (HOT, WARM, COLD) - importance_score: Updated importance score - """ - # Convert tier to lowercase for current_tier column - tier_lower = tier.lower() - - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE context_items - SET current_tier = ?, importance_score = ? - WHERE id = ? - """, - (tier_lower, importance_score, item_id), - ) - self.conn.commit() - - - - def delete_context_item(self, item_id: str) -> None: - """Delete a context item. - - Args: - item_id: Context item ID to delete (UUID string) - """ - cursor = self.conn.cursor() - cursor.execute("DELETE FROM context_items WHERE id = ?", (item_id,)) - self.conn.commit() - - - - def update_context_item_access(self, item_id: str) -> None: - """Update last_accessed timestamp and increment access_count. - - Args: - item_id: Context item ID (UUID string) - """ - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE context_items - SET last_accessed = CURRENT_TIMESTAMP, - access_count = access_count + 1 - WHERE id = ? - """, - (item_id,), - ) - self.conn.commit() - - - - def archive_cold_items(self, project_id: int, agent_id: str) -> int: - """Archive (delete) all COLD tier items for an agent (T053). - - This method is called during flash save to reduce memory footprint. - COLD tier items are fully archived in the checkpoint before deletion. - - Args: - project_id: Project ID the agent is working on - agent_id: Agent ID to archive COLD items for - - Returns: - int: Number of items archived (deleted) - - Example: - >>> db.archive_cold_items(123, "backend-worker-001") - 15 # 15 COLD items deleted - """ - cursor = self.conn.cursor() - - # Delete all COLD tier items for this agent on this project - cursor.execute( - """DELETE FROM context_items - WHERE project_id = ? - AND agent_id = ? - AND current_tier = 'cold'""", - (project_id, agent_id), - ) - - deleted_count = cursor.rowcount - self.conn.commit() - - return deleted_count - diff --git a/codeframe/persistence/repositories/correction_repository.py b/codeframe/persistence/repositories/correction_repository.py deleted file mode 100644 index a9ab42ad..00000000 --- a/codeframe/persistence/repositories/correction_repository.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Repository for Correction Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from typing import Optional -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class CorrectionRepository(BaseRepository): - """Repository for correction repository operations.""" - - - def create_correction_attempt( - self, - task_id: int, - attempt_number: int, - error_analysis: str, - fix_description: str, - code_changes: str = "", - test_result_id: Optional[int] = None, - ) -> int: - """ - Create a correction attempt record for a task. - - Args: - task_id: ID of the task being corrected - attempt_number: Which attempt this is (1-3) - error_analysis: Analysis of what went wrong - fix_description: Description of the fix attempted - code_changes: Actual code changes (diff format) - test_result_id: Optional link to test result after fix - - Returns: - ID of created correction attempt - - Raises: - ValueError: If attempt_number not in 1-3 range - """ - if not 1 <= attempt_number <= 3: - raise ValueError(f"attempt_number must be between 1 and 3, got {attempt_number}") - - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO correction_attempts - (task_id, attempt_number, error_analysis, fix_description, code_changes, test_result_id) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - task_id, - attempt_number, - error_analysis, - fix_description, - code_changes, - test_result_id, - ), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_correction_attempts_by_task(self, task_id: int) -> list[dict]: - """ - Get all correction attempts for a task, ordered by attempt number. - - Args: - task_id: ID of the task - - Returns: - List of correction attempt dictionaries - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT id, task_id, attempt_number, error_analysis, - fix_description, code_changes, test_result_id, created_at - FROM correction_attempts - WHERE task_id = ? - ORDER BY attempt_number ASC - """, - (task_id,), - ) - - columns = [desc[0] for desc in cursor.description] - return [dict(zip(columns, row)) for row in cursor.fetchall()] - - - - def get_latest_correction_attempt(self, task_id: int) -> Optional[dict]: - """ - Get the most recent correction attempt for a task. - - Args: - task_id: ID of the task - - Returns: - Correction attempt dictionary or None if no attempts exist - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT id, task_id, attempt_number, error_analysis, - fix_description, code_changes, test_result_id, created_at - FROM correction_attempts - WHERE task_id = ? - ORDER BY attempt_number DESC - LIMIT 1 - """, - (task_id,), - ) - - row = cursor.fetchone() - if row: - columns = [desc[0] for desc in cursor.description] - return dict(zip(columns, row)) - return None - - - - def count_correction_attempts(self, task_id: int) -> int: - """ - Count the number of correction attempts for a task. - - Args: - task_id: ID of the task - - Returns: - Number of correction attempts - """ - cursor = self.conn.cursor() - cursor.execute("SELECT COUNT(*) FROM correction_attempts WHERE task_id = ?", (task_id,)) - return cursor.fetchone()[0] - - # Task Dependency Management Methods (Sprint 4: cf-21) - diff --git a/codeframe/persistence/repositories/git_repository.py b/codeframe/persistence/repositories/git_repository.py deleted file mode 100644 index 18f6bdf8..00000000 --- a/codeframe/persistence/repositories/git_repository.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Repository for Git Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from datetime import datetime -from typing import List, Optional, Dict, Any -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class GitRepository(BaseRepository): - """Repository for git repository operations.""" - - def create_git_branch(self, issue_id: int, branch_name: str) -> int: - """Create a git branch record. - - Args: - issue_id: Issue ID this branch belongs to - branch_name: Git branch name - - Returns: - Branch ID - - Raises: - sqlite3.IntegrityError: If issue_id doesn't exist - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO git_branches (issue_id, branch_name, status) - VALUES (?, ?, ?) - """, - (issue_id, branch_name, "active"), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_branch_for_issue(self, issue_id: int) -> Optional[Dict[str, Any]]: - """Get the most recent active branch for an issue. - - Args: - issue_id: Issue ID - - Returns: - Branch dictionary or None if not found - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT * FROM git_branches - WHERE issue_id = ? AND status = 'active' - ORDER BY id DESC - LIMIT 1 - """, - (issue_id,), - ) - row = cursor.fetchone() - return dict(row) if row else None - - - - def mark_branch_merged(self, branch_id: int, merge_commit: str) -> int: - """Mark a branch as merged. - - Args: - branch_id: Branch ID - merge_commit: Git commit SHA of merge - - Returns: - Number of rows updated - """ - - cursor = self.conn.cursor() - merged_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - cursor.execute( - """ - UPDATE git_branches - SET status = ?, merge_commit = ?, merged_at = ? - WHERE id = ? - """, - ("merged", merge_commit, merged_at, branch_id), - ) - self.conn.commit() - return cursor.rowcount - - - - def mark_branch_abandoned(self, branch_id: int) -> int: - """Mark a branch as abandoned. - - Args: - branch_id: Branch ID - - Returns: - Number of rows updated - """ - cursor = self.conn.cursor() - cursor.execute( - "UPDATE git_branches SET status = ? WHERE id = ?", - ("abandoned", branch_id), - ) - self.conn.commit() - return cursor.rowcount - - - - def get_branch_statistics(self) -> Dict[str, int]: - """Get branch statistics across all statuses. - - Returns: - Dictionary with total, active, merged, abandoned counts - """ - cursor = self.conn.cursor() - - # Total count - cursor.execute("SELECT COUNT(*) FROM git_branches") - total = cursor.fetchone()[0] - - # Count by status - stats = {"total": total} - for status in ["active", "merged", "abandoned"]: - cursor.execute( - "SELECT COUNT(*) FROM git_branches WHERE status = ?", - (status,), - ) - stats[status] = cursor.fetchone()[0] - - return stats - - # Test Results methods (cf-42) - - - def delete_git_branch(self, branch_id: int) -> int: - """Delete a git branch record. - - Args: - branch_id: Branch ID - - Returns: - Number of rows deleted - """ - cursor = self.conn.cursor() - cursor.execute("DELETE FROM git_branches WHERE id = ?", (branch_id,)) - self.conn.commit() - return cursor.rowcount - - - - def get_branches_by_status(self, status: str) -> List[Dict[str, Any]]: - """Get all branches with given status. - - Args: - status: Branch status (active, merged, abandoned) - - Returns: - List of branch dictionaries - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT * FROM git_branches WHERE status = ? ORDER BY id", - (status,), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - - - def get_all_branches_for_issue(self, issue_id: int) -> List[Dict[str, Any]]: - """Get all branches for an issue (all statuses). - - Args: - issue_id: Issue ID - - Returns: - List of branch dictionaries - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT * FROM git_branches WHERE issue_id = ? ORDER BY id", - (issue_id,), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - - - def count_branches_for_issue(self, issue_id: int) -> int: - """Count branches for an issue. - - Args: - issue_id: Issue ID - - Returns: - Number of branches - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT COUNT(*) FROM git_branches WHERE issue_id = ?", - (issue_id,), - ) - return cursor.fetchone()[0] - - def get_branch_by_name_and_issues( - self, branch_name: str, issue_ids: List[int] - ) -> Optional[Dict[str, Any]]: - """Get a branch by name that belongs to one of the given issues. - - Single-query lookup for performance optimization. - - Args: - branch_name: Git branch name to find - issue_ids: List of issue IDs the branch must belong to - - Returns: - Branch dictionary or None if not found - """ - if not issue_ids: - return None - - cursor = self.conn.cursor() - placeholders = ",".join("?" * len(issue_ids)) - cursor.execute( - f""" - SELECT * FROM git_branches - WHERE branch_name = ? AND issue_id IN ({placeholders}) - LIMIT 1 - """, - (branch_name, *issue_ids), - ) - row = cursor.fetchone() - return dict(row) if row else None - diff --git a/codeframe/persistence/repositories/issue_repository.py b/codeframe/persistence/repositories/issue_repository.py deleted file mode 100644 index 5543fb45..00000000 --- a/codeframe/persistence/repositories/issue_repository.py +++ /dev/null @@ -1,502 +0,0 @@ -"""Repository for Issue Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -import json -import sqlite3 -from typing import List, Optional, Dict, Any, Union -import logging - -import aiosqlite - -from codeframe.core.models import ( - TaskStatus, - Issue, - IssueWithTaskCount, -) -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - -# Whitelist of allowed issue fields for updates (prevents SQL injection) -ALLOWED_ISSUE_FIELDS = { - "project_id", - "issue_number", - "title", - "description", - "status", - "priority", - "workflow_step", - "completed_at", - "depends_on", -} - - -class IssueRepository(BaseRepository): - """Repository for issue repository operations.""" - - - def create_issue(self, issue: Issue | dict) -> int: - """Create a new issue. - - Args: - issue: Issue object or dict to create - - Returns: - Created issue ID - - Raises: - sqlite3.IntegrityError: If issue_number already exists for project - """ - # Handle both Issue objects and dicts for test flexibility - if isinstance(issue, dict): - project_id = issue.get("project_id") - issue_number = issue.get("issue_number") - title = issue.get("title", "") - description = issue.get("description", "") - status = issue.get("status", "pending") - priority = issue.get("priority", 2) - workflow_step = issue.get("workflow_step", 1) - else: - project_id = issue.project_id - issue_number = issue.issue_number - title = issue.title - description = issue.description - status = issue.status.value if hasattr(issue.status, "value") else issue.status - priority = issue.priority - workflow_step = issue.workflow_step - - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO issues ( - project_id, issue_number, title, description, - status, priority, workflow_step - ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - project_id, - issue_number, - title, - description, - status, - priority, - workflow_step, - ), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_issue(self, issue_id: int) -> Optional[Issue]: - """Get issue by ID. - - Args: - issue_id: Issue ID - - Returns: - Issue object or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)) - row = cursor.fetchone() - return self._row_to_issue(row) if row else None - - - - def get_project_issues(self, project_id: int) -> List[Issue]: - """Get all issues for a project. - - Args: - project_id: Project ID - - Returns: - List of Issue objects ordered by issue_number - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT * FROM issues WHERE project_id = ? ORDER BY issue_number", - (project_id,), - ) - rows = cursor.fetchall() - return [self._row_to_issue(row) for row in rows] - - - def get_issues_with_tasks(self, project_id: int, include_tasks: bool = False) -> Dict[str, Any]: - """Get issues for a project with optional tasks. - - Args: - project_id: Project ID - include_tasks: Whether to include tasks in response - - Returns: - Dictionary with issues, total_issues, total_tasks - """ - - cursor = self.conn.cursor() - - # Get all issues for project - cursor.execute( - """ - SELECT * FROM issues - WHERE project_id = ? - ORDER BY issue_number - """, - (project_id,), - ) - issue_rows = cursor.fetchall() - - # Format issues according to API contract - issues = [] - total_tasks = 0 - - for issue_row in issue_rows: - issue_dict = dict(issue_row) - - # Parse depends_on from JSON using centralized helper - depends_on = self._parse_depends_on(issue_dict.get("depends_on")) - - # Format issue according to API contract - formatted_issue = { - "id": str(issue_dict["id"]), - "issue_number": issue_dict["issue_number"], - "title": issue_dict["title"], - "description": issue_dict["description"] or "", - "status": issue_dict["status"], - "priority": issue_dict["priority"], - "depends_on": depends_on, - "proposed_by": "agent", # Default for now - "created_at": self._ensure_rfc3339(issue_dict["created_at"]), - "updated_at": self._ensure_rfc3339(issue_dict["created_at"]), # Use created_at for now - "completed_at": ( - self._ensure_rfc3339(issue_dict["completed_at"]) - if issue_dict.get("completed_at") - else None - ), - } - - # Include tasks if requested - if include_tasks: - # Get tasks for this issue - cursor.execute( - """ - SELECT * FROM tasks - WHERE issue_id = ? - ORDER BY task_number - """, - (issue_dict["id"],), - ) - task_rows = cursor.fetchall() - - # Format tasks according to API contract - tasks = [] - for task_row in task_rows: - task_dict = dict(task_row) - - # Parse depends_on from JSON - depends_on = [] - depends_on_str = task_dict.get("depends_on") - if depends_on_str: - try: - depends_on = json.loads(depends_on_str) - # Ensure it's a list - if not isinstance(depends_on, list): - depends_on = [] - except (json.JSONDecodeError, TypeError): - # If parsing fails, return empty list - depends_on = [] - - formatted_task = { - "id": str(task_dict["id"]), - "task_number": task_dict["task_number"], - "title": task_dict["title"], - "description": task_dict["description"] or "", - "status": task_dict["status"], - "depends_on": depends_on, - "proposed_by": "agent", # Default for now - "created_at": self._ensure_rfc3339(task_dict["created_at"]), - "updated_at": self._ensure_rfc3339( - task_dict["created_at"] - ), # Use created_at for now - "completed_at": ( - self._ensure_rfc3339(task_dict["completed_at"]) - if task_dict.get("completed_at") - else None - ), - } - tasks.append(formatted_task) - total_tasks += 1 - - formatted_issue["tasks"] = tasks - else: - # Count tasks even if not including them - cursor.execute( - "SELECT COUNT(*) FROM tasks WHERE issue_id = ?", - (issue_dict["id"],), - ) - task_count = cursor.fetchone()[0] - total_tasks += task_count - - issues.append(formatted_issue) - - return { - "issues": issues, - "total_issues": len(issues), - "total_tasks": total_tasks, - } - - # Git Branches methods (cf-33) - - - def list_issues_with_progress(self, project_id: int) -> List[Dict[str, Any]]: - """List issues with their progress metrics. - - Args: - project_id: Project ID - - Returns: - List of issue dictionaries with task_count field - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT i.*, COUNT(t.id) as task_count - FROM issues i - LEFT JOIN tasks t ON t.issue_id = i.id - WHERE i.project_id = ? - GROUP BY i.id - ORDER BY i.issue_number - """, - (project_id,), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - # PRD methods (cf-26) - - - def get_issue_with_task_counts(self, issue_id: int) -> Optional[IssueWithTaskCount]: - """Get issue with count of associated tasks. - - Args: - issue_id: Issue ID - - Returns: - IssueWithTaskCount object (using composition) or None if not found - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT i.*, COUNT(t.id) as task_count - FROM issues i - LEFT JOIN tasks t ON t.issue_id = i.id - WHERE i.id = ? - GROUP BY i.id - """, - (issue_id,), - ) - row = cursor.fetchone() - if not row: - return None - - # Use _row_to_issue for consistent parsing, then wrap with task count - issue = self._row_to_issue(row) - return IssueWithTaskCount( - issue=issue, - task_count=row["task_count"], - ) - - - - def _parse_depends_on(self, depends_on_str: Optional[str]) -> List[str]: - """Parse depends_on JSON string into a list of dependency IDs. - - The depends_on field is stored as a JSON array of issue/task IDs. - IDs may be stored as integers or strings in the JSON; this method - coerces all values to strings for consistency with API contracts. - Handles NULL values, invalid JSON, and non-list JSON gracefully. - - Args: - depends_on_str: JSON string from database, or None - - Returns: - List of dependency IDs as strings, or empty list if parsing fails - """ - if not depends_on_str: - return [] - try: - parsed = json.loads(depends_on_str) - # Ensure it's a list - non-list JSON returns empty list - if isinstance(parsed, list): - # Coerce all values to strings for consistent API contract - return [str(x) for x in parsed] - return [] - except (json.JSONDecodeError, TypeError): - # Invalid JSON returns empty list - return [] - - def _row_to_issue(self, row: Union[sqlite3.Row, aiosqlite.Row]) -> Issue: - """Convert a database row to an Issue object. - - Args: - row: SQLite Row object from issues table (sync or async) - - Returns: - Issue dataclass instance - - Note: - Both sqlite3.Row and aiosqlite.Row support dictionary-style access - via row["column_name"], which this method relies on. - """ - row_id = row["id"] - - # Parse timestamps - created_at should never be NULL (enforced by schema) - created_at = self._parse_datetime(row["created_at"], "created_at", row_id) - if created_at is None: - raise ValueError( - f"Issue {row_id} has NULL created_at - database integrity issue. " - "The schema enforces NOT NULL on created_at." - ) - completed_at = self._parse_datetime(row["completed_at"], "completed_at", row_id) - - # Convert status string to enum - status = TaskStatus.PENDING - if row["status"]: - try: - status = TaskStatus(row["status"]) - except ValueError: - logger.warning( - f"Invalid issue status '{row['status']}' for issue {row_id}, defaulting to PENDING" - ) - - return Issue( - id=row_id, - project_id=row["project_id"], - issue_number=row["issue_number"] or "", - title=row["title"] or "", - description=row["description"] or "", - status=status, - priority=row["priority"] if row["priority"] is not None else 2, - workflow_step=row["workflow_step"] if row["workflow_step"] is not None else 1, - created_at=created_at, - completed_at=completed_at, - ) - - - def list_issues(self, project_id: int) -> List[Dict[str, Any]]: - """Alias for get_project_issues for test compatibility.""" - return self.get_project_issues(project_id) - - - - def update_issue(self, issue_id: int, updates: Dict[str, Any]) -> int: - """Update issue fields. - - Args: - issue_id: Issue ID to update - updates: Dictionary of fields to update - - Returns: - Number of rows affected - - Raises: - ValueError: If any update key is not in the allowed fields whitelist - """ - if not updates: - return 0 - - # Validate all keys against whitelist to prevent SQL injection - invalid_fields = set(updates.keys()) - ALLOWED_ISSUE_FIELDS - if invalid_fields: - raise ValueError( - f"Invalid issue fields: {invalid_fields}. " - f"Allowed fields: {ALLOWED_ISSUE_FIELDS}" - ) - - fields = [] - values = [] - for key, value in updates.items(): - # Safe to use key here since it's been validated against whitelist - fields.append(f"{key} = ?") - values.append(value) - - values.append(issue_id) - - query = f"UPDATE issues SET {', '.join(fields)} WHERE id = ?" - - cursor = self.conn.cursor() - cursor.execute(query, values) - self.conn.commit() - - return cursor.rowcount - - - - def get_issue_completion_status(self, issue_id: int) -> Dict[str, Any]: - """Calculate issue completion based on task statuses. - - Args: - issue_id: Issue ID - - Returns: - Dictionary with total_tasks, completed_tasks, completion_percentage - """ - cursor = self.conn.cursor() - - # Get total task count - cursor.execute("SELECT COUNT(*) FROM tasks WHERE issue_id = ?", (issue_id,)) - total_tasks = cursor.fetchone()[0] - - # Get completed task count - cursor.execute( - "SELECT COUNT(*) FROM tasks WHERE issue_id = ? AND status = ?", - (issue_id, "completed"), - ) - completed_tasks = cursor.fetchone()[0] - - # Calculate percentage - completion_percentage = (completed_tasks / total_tasks * 100) if total_tasks > 0 else 0.0 - - return { - "total_tasks": total_tasks, - "completed_tasks": completed_tasks, - "completion_percentage": completion_percentage, - } - - def delete_all_project_issues(self, project_id: int, cursor: sqlite3.Cursor = None) -> int: - """Delete all issues for a project. - - Note: Tasks must be deleted before issues due to FK constraints. - Use Database.delete_project_tasks_and_issues() for the complete - cascading delete operation. - - Args: - project_id: Project ID to delete issues for - cursor: Optional cursor for transaction support. If provided, - the caller is responsible for commit/rollback. - - Returns: - Number of issues deleted - """ - own_cursor = cursor is None - if own_cursor: - cursor = self.conn.cursor() - - try: - cursor.execute( - "DELETE FROM issues WHERE project_id = ?", - (project_id,), - ) - issue_count = cursor.rowcount - - if own_cursor: - self.conn.commit() - - return issue_count - - except Exception: - if own_cursor: - self.conn.rollback() - raise diff --git a/codeframe/persistence/repositories/lint_repository.py b/codeframe/persistence/repositories/lint_repository.py deleted file mode 100644 index e14d7347..00000000 --- a/codeframe/persistence/repositories/lint_repository.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Repository for Lint Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class LintRepository(BaseRepository): - """Repository for lint repository operations.""" - - - def create_lint_result( - self, - task_id: int, - linter: str, - error_count: int, - warning_count: int, - files_linted: int, - output: str, - ) -> int: - """Store lint execution result. - - Args: - task_id: Task ID - linter: Linter tool name ('ruff', 'eslint', 'other') - error_count: Number of errors - warning_count: Number of warnings - files_linted: Number of files checked - output: Full lint output (JSON or text) - - Returns: - Lint result ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO lint_results (task_id, linter, error_count, warning_count, files_linted, output) - VALUES (?, ?, ?, ?, ?, ?) - """, - (task_id, linter, error_count, warning_count, files_linted, output), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_lint_results_for_task(self, task_id: int) -> list[dict]: - """Get all lint results for a task. - - Args: - task_id: Task ID - - Returns: - List of lint result dictionaries - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT id, task_id, linter, error_count, warning_count, files_linted, output, created_at - FROM lint_results - WHERE task_id = ? - ORDER BY created_at DESC - """, - (task_id,), - ) - return [dict(row) for row in cursor.fetchall()] - - - - def get_lint_trend(self, project_id: int, days: int = 7) -> list[dict]: - """Get lint error trend for project over time. - - Args: - project_id: Project ID - days: Number of days to look back - - Returns: - List of {date, linter, error_count, warning_count} dictionaries - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT - DATE(lr.created_at) as date, - lr.linter, - SUM(lr.error_count) as error_count, - SUM(lr.warning_count) as warning_count - FROM lint_results lr - JOIN tasks t ON lr.task_id = t.id - WHERE t.project_id = ? - AND lr.created_at >= datetime('now', '-' || ? || ' days') - GROUP BY DATE(lr.created_at), lr.linter - ORDER BY date DESC - """, - (project_id, days), - ) - return [dict(row) for row in cursor.fetchall()] - diff --git a/codeframe/persistence/repositories/memory_repository.py b/codeframe/persistence/repositories/memory_repository.py deleted file mode 100644 index e11b44e0..00000000 --- a/codeframe/persistence/repositories/memory_repository.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Repository for Memory Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from typing import List, Optional, Dict, Any -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class MemoryRepository(BaseRepository): - """Repository for memory repository operations.""" - - - def create_memory( - self, - project_id: int, - category: str, - key: str, - value: str, - ) -> int: - """Create a memory entry. - - Args: - project_id: Project ID - category: Memory category (pattern, decision, gotcha, preference, conversation) - key: Memory key (role for conversation: user_1, assistant_1, etc.) - value: Memory value (content) - - Returns: - Memory ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO memory (project_id, category, key, value) - VALUES (?, ?, ?, ?) - """, - (project_id, category, key, value), - ) - self.conn.commit() - return cursor.lastrowid - - def upsert_memory( - self, - project_id: int, - category: str, - key: str, - value: str, - ) -> int: - """Create or update a memory entry. - - Uses INSERT ... ON CONFLICT to preserve original id and created_at. - - Args: - project_id: Project ID - category: Memory category - key: Memory key - value: Memory value (content) - - Returns: - Memory ID (existing or newly created) - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO memory (project_id, category, key, value) - VALUES (?, ?, ?, ?) - ON CONFLICT(project_id, category, key) DO UPDATE SET - value = excluded.value, - updated_at = CURRENT_TIMESTAMP - """, - (project_id, category, key, value), - ) - self.conn.commit() - - # Query the actual row id (lastrowid unreliable after conflict) - cursor.execute( - "SELECT id FROM memory WHERE project_id = ? AND category = ? AND key = ?", - (project_id, category, key), - ) - row = cursor.fetchone() - return row[0] if row else cursor.lastrowid - - def get_memory(self, memory_id: int) -> Optional[Dict[str, Any]]: - """Get memory entry by ID. - - Args: - memory_id: Memory ID - - Returns: - Memory dictionary or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM memory WHERE id = ?", (memory_id,)) - row = cursor.fetchone() - return dict(row) if row else None - - - - def get_project_memories(self, project_id: int) -> List[Dict[str, Any]]: - """Get all memory entries for a project. - - Args: - project_id: Project ID - - Returns: - List of memory dictionaries - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT * FROM memory WHERE project_id = ? ORDER BY created_at", - (project_id,), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - def get_memories_by_category(self, project_id: int, category: str) -> List[Dict[str, Any]]: - """Get memory entries for a project filtered by category. - - Args: - project_id: Project ID - category: Memory category to filter by - - Returns: - List of memory dictionaries matching the category - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT * FROM memory WHERE project_id = ? AND category = ? ORDER BY created_at", - (project_id, category), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - - - def get_conversation(self, project_id: int) -> List[Dict[str, Any]]: - """Get conversation history for a project. - - Conversation messages are stored in memory table with category='conversation'. - - Args: - project_id: Project ID - - Returns: - List of conversation message dictionaries ordered by insertion (id) - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT * FROM memory - WHERE project_id = ? AND category = 'conversation' - ORDER BY id - """, - (project_id,), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - # Additional Issue methods (cf-16.2) diff --git a/codeframe/persistence/repositories/pr_repository.py b/codeframe/persistence/repositories/pr_repository.py deleted file mode 100644 index ada0606b..00000000 --- a/codeframe/persistence/repositories/pr_repository.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Repository for Pull Request operations. - -Handles database operations for GitHub Pull Request tracking. -Part of Sprint 11 - GitHub PR Integration. -""" - -from datetime import datetime, UTC -from typing import Any, Dict, List, Optional -import logging - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class PRRepository(BaseRepository): - """Repository for pull request database operations.""" - - def create_pr( - self, - project_id: int, - issue_id: Optional[int], - branch_name: str, - title: str, - body: str, - base_branch: str, - head_branch: str, - status: str = "open", - ) -> int: - """Create a new pull request record. - - Args: - project_id: Project ID this PR belongs to - issue_id: Optional associated issue ID - branch_name: Git branch name - title: PR title - body: PR description - base_branch: Target branch (e.g., "main") - head_branch: Source branch with changes - status: Initial status (default: "open") - - Returns: - PR ID - - Raises: - sqlite3.IntegrityError: If project_id doesn't exist - """ - cursor = self._execute( - """ - INSERT INTO pull_requests ( - project_id, issue_id, branch_name, title, body, - base_branch, head_branch, status - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - (project_id, issue_id, branch_name, title, body, base_branch, head_branch, status), - ) - self._commit() - return cursor.lastrowid - - def get_pr(self, pr_id: int) -> Optional[Dict[str, Any]]: - """Get a pull request by its ID. - - Args: - pr_id: Pull request ID - - Returns: - PR dictionary or None if not found - """ - row = self._fetchone( - "SELECT * FROM pull_requests WHERE id = ?", - (pr_id,), - ) - return self._row_to_dict(row) if row else None - - def get_pr_by_number(self, project_id: int, pr_number: int) -> Optional[Dict[str, Any]]: - """Get a pull request by its GitHub PR number. - - Args: - project_id: Project ID - pr_number: GitHub PR number - - Returns: - PR dictionary or None if not found - """ - row = self._fetchone( - """ - SELECT * FROM pull_requests - WHERE project_id = ? AND pr_number = ? - """, - (project_id, pr_number), - ) - return self._row_to_dict(row) if row else None - - def list_prs( - self, project_id: int, status: Optional[str] = None - ) -> List[Dict[str, Any]]: - """List pull requests for a project. - - Args: - project_id: Project ID - status: Optional filter by status (open, merged, closed, draft) - - Returns: - List of PR dictionaries - """ - if status: - rows = self._fetchall( - """ - SELECT * FROM pull_requests - WHERE project_id = ? AND status = ? - ORDER BY created_at DESC - """, - (project_id, status), - ) - else: - rows = self._fetchall( - """ - SELECT * FROM pull_requests - WHERE project_id = ? - ORDER BY created_at DESC - """, - (project_id,), - ) - - return [self._row_to_dict(row) for row in rows] - - def update_pr_github_data( - self, - pr_id: int, - pr_number: int, - pr_url: str, - github_created_at: datetime, - ) -> None: - """Update PR with data from GitHub API response. - - Args: - pr_id: Local PR ID - pr_number: GitHub PR number - pr_url: GitHub PR URL - github_created_at: When PR was created on GitHub - - Raises: - ValueError: If pr_id does not exist - """ - # Ensure datetime is UTC-aware for consistent storage - if github_created_at.tzinfo is None: - github_created_at = github_created_at.replace(tzinfo=UTC) - - cursor = self._execute( - """ - UPDATE pull_requests - SET pr_number = ?, pr_url = ?, github_created_at = ? - WHERE id = ? - """, - (pr_number, pr_url, github_created_at.isoformat(), pr_id), - ) - if cursor.rowcount == 0: - raise ValueError(f"PR id {pr_id} not found") - self._commit() - - def update_pr_status( - self, - pr_id: int, - status: str, - merge_commit_sha: Optional[str] = None, - merged_at: Optional[datetime] = None, - ) -> None: - """Update pull request status. - - Args: - pr_id: PR ID - status: New status (open, merged, closed, draft) - merge_commit_sha: Merge commit SHA (for merged PRs) - merged_at: When PR was merged (auto-set if not provided) - - Raises: - ValueError: If pr_id does not exist - """ - now = datetime.now(UTC) - - if status == "merged": - # Use provided merged_at or current time - if merged_at is None: - merged_at = now - # Ensure datetime is UTC-aware - elif merged_at.tzinfo is None: - merged_at = merged_at.replace(tzinfo=UTC) - - cursor = self._execute( - """ - UPDATE pull_requests - SET status = ?, merge_commit_sha = ?, merged_at = ? - WHERE id = ? - """, - (status, merge_commit_sha, merged_at.isoformat(), pr_id), - ) - elif status == "closed": - cursor = self._execute( - """ - UPDATE pull_requests - SET status = ?, closed_at = ? - WHERE id = ? - """, - (status, now.isoformat(), pr_id), - ) - else: - cursor = self._execute( - """ - UPDATE pull_requests - SET status = ? - WHERE id = ? - """, - (status, pr_id), - ) - - if cursor.rowcount == 0: - raise ValueError(f"PR id {pr_id} not found") - self._commit() - - def get_pr_for_branch( - self, project_id: int, branch_name: str - ) -> Optional[Dict[str, Any]]: - """Find a PR by branch name. - - Args: - project_id: Project ID - branch_name: Git branch name - - Returns: - PR dictionary or None if not found - """ - row = self._fetchone( - """ - SELECT * FROM pull_requests - WHERE project_id = ? AND branch_name = ? - ORDER BY created_at DESC - LIMIT 1 - """, - (project_id, branch_name), - ) - return self._row_to_dict(row) if row else None - - def delete_pr(self, pr_id: int) -> int: - """Delete a pull request record. - - Args: - pr_id: PR ID - - Returns: - Number of rows deleted - """ - cursor = self._execute("DELETE FROM pull_requests WHERE id = ?", (pr_id,)) - self._commit() - return cursor.rowcount diff --git a/codeframe/persistence/repositories/project_repository.py b/codeframe/persistence/repositories/project_repository.py deleted file mode 100644 index 5e5aeb63..00000000 --- a/codeframe/persistence/repositories/project_repository.py +++ /dev/null @@ -1,684 +0,0 @@ -"""Repository for Project Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -import json -import os -import sqlite3 -from datetime import datetime, timezone, timedelta -from typing import List, Optional, Dict, Any, Union -import logging - -import aiosqlite - -from codeframe.core.models import ( - ProjectStatus, - ProjectPhase, - SourceType, - Project, - Task, -) -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - -# Audit verbosity configuration -AUDIT_VERBOSITY = os.getenv("AUDIT_VERBOSITY", "low").lower() -if AUDIT_VERBOSITY not in ("low", "high"): - logger.warning(f"Invalid AUDIT_VERBOSITY='{AUDIT_VERBOSITY}', defaulting to 'low'") - AUDIT_VERBOSITY = "low" - -# Whitelist of allowed project fields for updates (prevents SQL injection) -ALLOWED_PROJECT_FIELDS = { - "name", - "description", - "user_id", - "source_type", - "source_location", - "source_branch", - "workspace_path", - "git_initialized", - "current_commit", - "status", - "phase", - "paused_at", - "config", -} - - -class ProjectRepository(BaseRepository): - """Repository for project repository operations.""" - - - - def create_project( - self, - name: str, - description: str, - source_type: str = "empty", - source_location: Optional[str] = None, - source_branch: str = "main", - workspace_path: Optional[str] = None, - user_id: Optional[int] = None, - **kwargs, - ) -> int: - """Create a new project. - - Args: - name: Project name - description: Project description/purpose - source_type: Source type (git_remote, local_path, upload, empty) - source_location: Git URL, local path, or upload filename - source_branch: Git branch (for git_remote) - workspace_path: Path to workspace directory - user_id: ID of the user creating the project (owner) - **kwargs: Additional fields (config, status, etc.) - - Returns: - Created project ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO projects ( - name, description, source_type, source_location, - source_branch, workspace_path, git_initialized, status, user_id - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - name, - description, - source_type, - source_location, - source_branch, - workspace_path or "", - False, # Will be set to True after workspace initialization - "init", # Default status - user_id, - ), - ) - self.conn.commit() - project_id = cursor.lastrowid - - # Automatically add owner to project_users table - if user_id is not None: - cursor.execute( - """ - INSERT INTO project_users (project_id, user_id, role) - VALUES (?, ?, 'owner') - """, - (project_id, user_id), - ) - self.conn.commit() - - # Log project creation - if user_id is not None: - from codeframe.lib.audit_logger import AuditLogger, AuditEventType - audit = AuditLogger(self._database if self._database else self) - audit.log_project_event( - event_type=AuditEventType.PROJECT_CREATED, - user_id=user_id, - project_id=project_id, - ip_address=None, # TODO: Pass from request context - metadata={"name": name, "source_type": source_type}, - ) - - return project_id - - - - def get_project(self, project_identifier: int | str) -> Optional[dict]: - """Get project by ID or name. - - Args: - project_identifier: Project ID (int) or project name (str) - - Returns: - Project dictionary or None if not found - """ - if self._sync_lock is not None: - with self._sync_lock: - cursor = self.conn.cursor() - if isinstance(project_identifier, int): - cursor.execute("SELECT * FROM projects WHERE id = ?", (project_identifier,)) - else: - cursor.execute("SELECT * FROM projects WHERE name = ?", (project_identifier,)) - row = cursor.fetchone() - return dict(row) if row else None - else: - cursor = self.conn.cursor() - if isinstance(project_identifier, int): - cursor.execute("SELECT * FROM projects WHERE id = ?", (project_identifier,)) - else: - cursor.execute("SELECT * FROM projects WHERE name = ?", (project_identifier,)) - row = cursor.fetchone() - return dict(row) if row else None - - - - def list_projects(self) -> List[Dict[str, Any]]: - """List all projects with progress metrics. - - Note: - Returns dicts rather than Project objects because this method adds - computed 'progress' metrics that aren't part of the Project schema. - Use get_project() for typed Project returns. - - Returns: - List of project dictionaries, each with a 'progress' field containing: - - completed_tasks: Number of tasks with status='completed' - - total_tasks: Total number of tasks - - percentage: Completion percentage (0.0-100.0) - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM projects ORDER BY created_at DESC") - rows = cursor.fetchall() - - projects = [] - for row in rows: - project = dict(row) - project_id = project["id"] - - # Calculate progress metrics for this project - progress = self._calculate_project_progress(project_id) - project["progress"] = progress - - projects.append(project) - - return projects - - - - def update_project( - self, - project_id: int, - updates: Dict[str, Any], - user_id: Optional[int] = None, - ip_address: Optional[str] = None, - ) -> int: - """Update project fields. - - Args: - project_id: Project ID to update - updates: Dictionary of fields to update - user_id: ID of user performing update (for audit logging) - ip_address: Client IP address (for audit logging) - - Returns: - Number of rows affected - - Raises: - ValueError: If any update key is not in the allowed fields whitelist - """ - if not updates: - return 0 - - # Validate all keys against whitelist to prevent SQL injection - invalid_fields = set(updates.keys()) - ALLOWED_PROJECT_FIELDS - if invalid_fields: - raise ValueError( - f"Invalid project fields: {invalid_fields}. " - f"Allowed fields: {ALLOWED_PROJECT_FIELDS}" - ) - - # Build UPDATE query dynamically - fields = [] - values = [] - for key, value in updates.items(): - # Safe to use key here since it's been validated against whitelist - fields.append(f"{key} = ?") - # Handle enum values - if isinstance(value, ProjectStatus): - values.append(value.value) - else: - values.append(value) - - values.append(project_id) - - query = f"UPDATE projects SET {', '.join(fields)} WHERE id = ?" - - cursor = self.conn.cursor() - cursor.execute(query, values) - self.conn.commit() - - rows_affected = cursor.rowcount - - # Log project update if user_id is provided - # NOTE: Audit logging happens after commit, so if audit fails, the update - # is already persisted. This is intentional - we prefer data consistency - # over audit completeness. Failed audits are logged but don't roll back. - if user_id is not None and rows_affected > 0: - # Import locally to avoid circular dependency: audit_logger -> database -> project_repository - from codeframe.lib.audit_logger import AuditLogger, AuditEventType - audit = AuditLogger(self._database if self._database else self) - audit.log_project_event( - event_type=AuditEventType.PROJECT_UPDATED, - user_id=user_id, - project_id=project_id, - ip_address=ip_address, - metadata={"updated_fields": list(updates.keys())}, - ) - - return rows_affected - - - - def delete_project( - self, - project_id: int, - user_id: Optional[int] = None, - ip_address: Optional[str] = None, - ) -> None: - """Delete a project. - - Args: - project_id: Project ID to delete - user_id: ID of user performing deletion (for audit logging) - ip_address: Client IP address (for audit logging) - """ - # Get project name before deletion for audit log - project_name = None - if user_id is not None: - project = self.get_project(project_id) - if project: - project_name = project.get("name") - - cursor = self.conn.cursor() - cursor.execute("DELETE FROM projects WHERE id = ?", (project_id,)) - self.conn.commit() - - rows_affected = cursor.rowcount - - # Log project deletion if user_id is provided and deletion actually occurred - # NOTE: Audit logging happens after commit, so if audit fails, the deletion - # is already persisted. This is intentional - we prefer data consistency - # over audit completeness. Failed audits are logged but don't roll back. - if user_id is not None and rows_affected > 0: - # Import locally to avoid circular dependency: audit_logger -> database -> project_repository - from codeframe.lib.audit_logger import AuditLogger, AuditEventType - audit = AuditLogger(self._database if self._database else self) - audit.log_project_event( - event_type=AuditEventType.PROJECT_DELETED, - user_id=user_id, - project_id=project_id, - ip_address=ip_address, - metadata={"name": project_name} if project_name else None, - ) - - - - def _row_to_project(self, row: Union[sqlite3.Row, aiosqlite.Row]) -> Project: - """Convert a database row to a Project object. - - Args: - row: SQLite Row object from projects table (sync or async) - - Returns: - Project dataclass instance - - Note: - Both sqlite3.Row and aiosqlite.Row support dictionary-style access - via row["column_name"], which this method relies on. - """ - row_id = row["id"] - - # Parse timestamps - created_at = self._parse_datetime(row["created_at"], "created_at", row_id) - if created_at is None: - logger.warning( - f"Project {row_id} has NULL created_at - using datetime.now() as fallback" - ) - created_at = datetime.now() - paused_at = self._parse_datetime(row["paused_at"], "paused_at", row_id) - - # Convert status string to enum - status = ProjectStatus.INIT - if row["status"]: - try: - status = ProjectStatus(row["status"]) - except ValueError: - logger.warning( - f"Invalid project status '{row['status']}' for project {row_id}, defaulting to INIT" - ) - - # Convert phase string to enum - phase = ProjectPhase.DISCOVERY - if row["phase"]: - try: - phase = ProjectPhase(row["phase"]) - except ValueError: - logger.warning( - f"Invalid project phase '{row['phase']}' for project {row_id}, defaulting to DISCOVERY" - ) - - # Convert source_type string to enum - source_type = SourceType.EMPTY - if row["source_type"]: - try: - source_type = SourceType(row["source_type"]) - except ValueError: - logger.warning( - f"Invalid source_type '{row['source_type']}' for project {row_id}, defaulting to EMPTY" - ) - - # Parse config JSON - config = None - if row["config"]: - try: - config = json.loads(row["config"]) if isinstance(row["config"], str) else row["config"] - except (json.JSONDecodeError, TypeError) as e: - logger.warning(f"Failed to parse config for project {row_id}: {e}") - - return Project( - id=row_id, - name=row["name"] or "", - description=row["description"] or "", - source_type=source_type, - source_location=row["source_location"], - source_branch=row["source_branch"] or "main", - workspace_path=row["workspace_path"] or "", - git_initialized=bool(row["git_initialized"]), - current_commit=row["current_commit"], - status=status, - phase=phase, - created_at=created_at, - paused_at=paused_at, - config=config, - ) - - - - def _calculate_project_progress(self, project_id: int) -> Dict[str, Any]: - """Calculate task completion progress for a project. - - Uses a single SQL query to efficiently get both total and completed task counts. - - Args: - project_id: Project ID - - Returns: - Dictionary with completed_tasks, total_tasks, and percentage - """ - cursor = self.conn.cursor() - - # Get both counts in a single query using SUM with CASE - cursor.execute( - """ - SELECT - COUNT(*) as total_tasks, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_tasks - FROM tasks - WHERE project_id = ? - """, - (project_id,), - ) - row = cursor.fetchone() - - total_tasks = row["total_tasks"] - completed_tasks = row["completed_tasks"] or 0 # Handle NULL when no tasks - - # Calculate completion percentage - percentage = (completed_tasks / total_tasks * 100.0) if total_tasks > 0 else 0.0 - - return { - "completed_tasks": completed_tasks, - "total_tasks": total_tasks, - "percentage": percentage, - } - - - - def get_project_tasks(self, project_id: int) -> List[Task]: - """Get all tasks for a project (all statuses). - - Args: - project_id: Project ID - - Returns: - List of Task objects ordered by task_number - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT * FROM tasks WHERE project_id = ? ORDER BY task_number", - (project_id,), - ) - rows = cursor.fetchall() - # Use TaskRepository's _row_to_task method (cross-repository call) - if self._database: - return [self._database.tasks._row_to_task(row) for row in rows] - # Fallback for standalone repository usage (testing) - from codeframe.persistence.repositories.task_repository import TaskRepository - task_repo = TaskRepository(sync_conn=self.conn) - return [task_repo._row_to_task(row) for row in rows] - - - - def get_project_stats(self, project_id: int) -> Dict[str, int]: - """Get project statistics for progress calculation. - - Args: - project_id: Project ID - - Returns: - Dict with keys: total_tasks, completed_tasks - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT - COUNT(*) as total_tasks, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_tasks - FROM tasks - WHERE project_id = ? - """, - (project_id,), - ) - row = cursor.fetchone() - return { - "total_tasks": row["total_tasks"] or 0, - "completed_tasks": row["completed_tasks"] or 0, - } - - # Checkpoint Management Methods (Sprint 10 Phase 4: US-3) - - - - def get_user_projects(self, user_id: int) -> List[Dict[str, Any]]: - """Get all projects accessible to a user with progress metrics. - - Returns projects where the user is either: - - The owner (projects.user_id matches) - - A collaborator/viewer (exists in project_users table) - - Performance: Single query using LEFT JOIN to calculate progress for all projects. - - Args: - user_id: ID of the user - - Returns: - List of project dictionaries with progress metrics - """ - cursor = self.conn.cursor() - - # Single query with progress calculation using LEFT JOIN and aggregation - # Fixes N+1 query issue: 100 projects = 1 query instead of 101 - cursor.execute( - """ - SELECT DISTINCT - p.*, - COALESCE(task_stats.total_tasks, 0) as total_tasks, - COALESCE(task_stats.completed_tasks, 0) as completed_tasks, - COALESCE(task_stats.percentage, 0.0) as percentage - FROM projects p - LEFT JOIN project_users pu ON p.id = pu.project_id - LEFT JOIN ( - SELECT - project_id, - COUNT(*) as total_tasks, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_tasks, - CASE - WHEN COUNT(*) > 0 - THEN CAST(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100.0 - ELSE 0.0 - END as percentage - FROM tasks - GROUP BY project_id - ) task_stats ON p.id = task_stats.project_id - WHERE p.user_id = ? OR pu.user_id = ? - ORDER BY p.created_at DESC - """, - (user_id, user_id), - ) - rows = cursor.fetchall() - - projects = [] - for row in rows: - project = dict(row) - - # Extract progress metrics from the joined columns - progress = { - "completed_tasks": project.pop("completed_tasks"), - "total_tasks": project.pop("total_tasks"), - "percentage": project.pop("percentage"), - } - project["progress"] = progress - - projects.append(project) - - return projects - - - - def user_has_project_access(self, user_id: int, project_id: int) -> bool: - """Check if a user has access to a project. - - Checks both ownership (projects.user_id) and collaborator access (project_users table). - - Performance Note: - By default, only access DENIALS are logged to avoid excessive DB writes. - Set AUDIT_VERBOSITY=high to log all access checks (owner + collaborator grants). - - Args: - user_id: ID of the user - project_id: ID of the project - - Returns: - True if user is owner or has collaborator/viewer access, False otherwise - """ - cursor = self.conn.cursor() - - # Check if user is the project owner - cursor.execute( - "SELECT 1 FROM projects WHERE id = ? AND user_id = ?", - (project_id, user_id), - ) - if cursor.fetchone(): - # Only log if verbose auditing enabled (performance optimization) - if AUDIT_VERBOSITY == "high": - from codeframe.lib.audit_logger import AuditLogger, AuditEventType - audit = AuditLogger(self._database if self._database else self) - audit.log_authz_event( - event_type=AuditEventType.AUTHZ_ACCESS_GRANTED, - user_id=user_id, - resource_type="project", - resource_id=project_id, - granted=True, - ip_address=None, # TODO: Pass from request context - metadata={"access_type": "owner"}, - ) - return True - - # Check if user has collaborator/viewer access - cursor.execute( - "SELECT 1 FROM project_users WHERE project_id = ? AND user_id = ?", - (project_id, user_id), - ) - has_access = cursor.fetchone() is not None - - # Log authorization result - from codeframe.lib.audit_logger import AuditLogger, AuditEventType - audit = AuditLogger(self._database if self._database else self) - if has_access: - # Only log if verbose auditing enabled (performance optimization) - if AUDIT_VERBOSITY == "high": - audit.log_authz_event( - event_type=AuditEventType.AUTHZ_ACCESS_GRANTED, - user_id=user_id, - resource_type="project", - resource_id=project_id, - granted=True, - ip_address=None, # TODO: Pass from request context - metadata={"access_type": "collaborator"}, - ) - else: - # ALWAYS log access denials for security monitoring - audit.log_authz_event( - event_type=AuditEventType.AUTHZ_ACCESS_DENIED, - user_id=user_id, - resource_type="project", - resource_id=project_id, - granted=False, - ip_address=None, # TODO: Pass from request context - metadata={"reason": "No access"}, - ) - - return has_access - - async def cleanup_expired_sessions(self) -> int: - """Delete expired sessions from the database. - - This should be called periodically (e.g., every hour) to prevent - the sessions table from growing indefinitely. - - Returns: - Number of sessions deleted - """ - conn = await self._get_async_conn() - - # Delete sessions where expires_at < now - cursor = await conn.execute( - """ - DELETE FROM sessions - WHERE datetime(expires_at) < datetime(?) - """, - (datetime.now(timezone.utc).isoformat(),), - ) - - deleted_count = cursor.rowcount - await conn.commit() - - return deleted_count - - async def cleanup_old_audit_logs(self, retention_days: int = 90) -> int: - """Delete audit logs older than the retention period. - - This should be called periodically (e.g., daily) to prevent - the audit_logs table from growing indefinitely. - - Args: - retention_days: Number of days to retain audit logs (default: 90) - - Returns: - Number of audit log entries deleted - """ - conn = await self._get_async_conn() - - # Calculate cutoff date - cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days) - - # Delete audit logs older than retention period - cursor = await conn.execute( - """ - DELETE FROM audit_logs - WHERE datetime(timestamp) < datetime(?) - """, - (cutoff_date.isoformat(),), - ) - - deleted_count = cursor.rowcount - await conn.commit() - - return deleted_count - diff --git a/codeframe/persistence/repositories/quality_repository.py b/codeframe/persistence/repositories/quality_repository.py deleted file mode 100644 index 31805944..00000000 --- a/codeframe/persistence/repositories/quality_repository.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Repository for Quality Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -import json -from typing import List, Dict, Any, TYPE_CHECKING -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -if TYPE_CHECKING: - from codeframe.core.models import QualityGateFailure - -logger = logging.getLogger(__name__) - - -class QualityRepository(BaseRepository): - """Repository for quality repository operations.""" - - - def update_quality_gate_status( - self, - task_id: int, - status: str, - failures: List["QualityGateFailure"], - ) -> None: - """Update task quality gate status and failures. - - This method is called by QualityGates after running all gates to store - the results in the tasks table. The status is stored in quality_gate_status - column and failures are stored as JSON in quality_gate_failures column. - - Args: - task_id: Task ID to update - status: Gate status - 'pending', 'running', 'passed', or 'failed' - failures: List of QualityGateFailure objects (empty if passed) - - Example: - >>> from codeframe.core.models import QualityGateFailure, QualityGateType, Severity - >>> failure = QualityGateFailure( - ... gate=QualityGateType.TESTS, - ... reason="2 tests failed", - ... severity=Severity.HIGH - ... ) - >>> db.update_quality_gate_status(task_id=123, status='failed', failures=[failure]) - """ - - cursor = self.conn.cursor() - - # Serialize failures to JSON - failures_json = json.dumps( - [ - { - "gate": f.gate.value if hasattr(f.gate, "value") else f.gate, - "reason": f.reason, - "details": f.details, - "severity": f.severity.value if hasattr(f.severity, "value") else f.severity, - } - for f in failures - ] - ) - - cursor.execute( - """ - UPDATE tasks - SET quality_gate_status = ?, - quality_gate_failures = ? - WHERE id = ? - """, - (status, failures_json, task_id), - ) - self.conn.commit() - - logger.info( - f"Updated quality gate status for task {task_id}: " - f"status={status}, failures={len(failures)}" - ) - - - - def get_quality_gate_status(self, task_id: int) -> Dict[str, Any]: - """Get quality gate status for a task. - - Args: - task_id: Task ID to query - - Returns: - Dictionary with keys: - - status: Gate status ('pending', 'running', 'passed', 'failed', or None) - - failures: List of failure dictionaries (empty if passed or None if not run) - - requires_human_approval: Boolean indicating if task requires approval - - Example: - >>> result = db.get_quality_gate_status(task_id=123) - >>> if result['status'] == 'failed': - ... for failure in result['failures']: - ... print(f"{failure['gate']}: {failure['reason']}") - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT quality_gate_status, quality_gate_failures, requires_human_approval - FROM tasks - WHERE id = ? - """, - (task_id,), - ) - row = cursor.fetchone() - - if not row: - return { - "status": None, - "failures": [], - "requires_human_approval": False, - } - - status, failures_json, requires_approval = row - - # Parse failures JSON - failures = [] - if failures_json: - try: - failures = json.loads(failures_json) - except json.JSONDecodeError: - logger.warning(f"Failed to parse quality_gate_failures JSON for task {task_id}") - failures = [] - - return { - "status": status, - "failures": failures, - "requires_human_approval": bool(requires_approval), - } - diff --git a/codeframe/persistence/repositories/review_repository.py b/codeframe/persistence/repositories/review_repository.py deleted file mode 100644 index c2e76892..00000000 --- a/codeframe/persistence/repositories/review_repository.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Repository for Review Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from typing import List, Optional, TYPE_CHECKING -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -if TYPE_CHECKING: - from codeframe.core.models import CodeReview - -logger = logging.getLogger(__name__) - - -class ReviewRepository(BaseRepository): - """Repository for review repository operations.""" - - - def save_code_review(self, review: "CodeReview") -> int: - """Save a code review finding to database. - - Args: - review: CodeReview object to save - - Returns: - ID of the created code_reviews record - """ - - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO code_reviews ( - task_id, agent_id, project_id, file_path, line_number, - severity, category, message, recommendation, code_snippet - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - review.task_id, - review.agent_id, - review.project_id, - review.file_path, - review.line_number, - review.severity.value if hasattr(review.severity, "value") else review.severity, - review.category.value if hasattr(review.category, "value") else review.category, - review.message, - review.recommendation, - review.code_snippet, - ), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_code_reviews( - self, - task_id: Optional[int] = None, - project_id: Optional[int] = None, - severity: Optional[str] = None, - ) -> List["CodeReview"]: - """Get code review findings. - - Args: - task_id: Filter by task ID - project_id: Filter by project ID - severity: Filter by severity level - - Returns: - List of CodeReview objects - """ - from codeframe.core.models import CodeReview, Severity, ReviewCategory - - cursor = self.conn.cursor() - - # Build query dynamically based on filters - conditions = [] - params = [] - - if task_id is not None: - conditions.append("task_id = ?") - params.append(task_id) - - if project_id is not None: - conditions.append("project_id = ?") - params.append(project_id) - - if severity is not None: - conditions.append("severity = ?") - params.append(severity) - - where_clause = " AND ".join(conditions) if conditions else "1=1" - - cursor.execute( - f""" - SELECT id, task_id, agent_id, project_id, file_path, line_number, - severity, category, message, recommendation, code_snippet, created_at - FROM code_reviews - WHERE {where_clause} - ORDER BY created_at DESC - """, - params, - ) - - reviews = [] - for row in cursor.fetchall(): - row_dict = dict(row) - # Convert string severity/category back to enums - reviews.append( - CodeReview( - id=row_dict["id"], - task_id=row_dict["task_id"], - agent_id=row_dict["agent_id"], - project_id=row_dict["project_id"], - file_path=row_dict["file_path"], - line_number=row_dict["line_number"], - severity=Severity(row_dict["severity"]), - category=ReviewCategory(row_dict["category"]), - message=row_dict["message"], - recommendation=row_dict["recommendation"], - code_snippet=row_dict["code_snippet"], - ) - ) - - return reviews - - - - def get_code_reviews_by_severity(self, project_id: int, severity: str) -> List["CodeReview"]: - """Get code reviews filtered by severity. - - Convenience method that calls get_code_reviews with severity filter. - - Args: - project_id: Project ID to filter by - severity: Severity level (critical, high, medium, low, info) - - Returns: - List of CodeReview objects - """ - return self.get_code_reviews(project_id=project_id, severity=severity) - - - - def get_code_reviews_by_project( - self, project_id: int, severity: Optional[str] = None - ) -> List["CodeReview"]: - """Get all code review findings for a project. - - Convenience method for fetching project-level review aggregations. - Returns all code reviews across all tasks in the project. - - Args: - project_id: Project ID to fetch reviews for - severity: Optional severity filter (critical, high, medium, low, info) - - Returns: - List of CodeReview objects ordered by creation time (newest first) - """ - return self.get_code_reviews(project_id=project_id, severity=severity) - - # ======================================================================== - # Quality Gate Methods (Sprint 10 Phase 3 - US-2) - # ======================================================================== - diff --git a/codeframe/persistence/repositories/task_repository.py b/codeframe/persistence/repositories/task_repository.py deleted file mode 100644 index 222d2565..00000000 --- a/codeframe/persistence/repositories/task_repository.py +++ /dev/null @@ -1,1129 +0,0 @@ -"""Repository for Task Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -import json -import sqlite3 -from typing import List, Optional, Dict, Any, Union -import logging - -import aiosqlite - -from codeframe.core.models import ( - Task, - TaskStatus, -) -from codeframe.persistence.repositories.base import BaseRepository - -# Evidence imports (lazily imported to avoid circular dependencies) -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from codeframe.enforcement.evidence_verifier import Evidence - -logger = logging.getLogger(__name__) - -# Whitelist of allowed task fields for updates (prevents SQL injection) -ALLOWED_TASK_FIELDS = { - "project_id", - "issue_id", - "task_number", - "parent_issue_number", - "title", - "description", - "status", - "assigned_to", - "depends_on", - "can_parallelize", - "priority", - "workflow_step", - "requires_mcp", - "estimated_tokens", - "actual_tokens", - "commit_sha", - "completed_at", - "quality_gate_status", - "quality_gate_failures", - "requires_human_approval", - # Effort estimation fields (Phase 1) - "estimated_hours", - "complexity_score", - "uncertainty_level", - "resource_requirements", - # Supervisor intervention context - "intervention_context", -} - - -class TaskRepository(BaseRepository): - """Repository for task repository operations.""" - - - def create_task(self, task: Task) -> int: - """Create a new task.""" - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO tasks ( - project_id, title, description, status, priority, workflow_step, requires_mcp, depends_on - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - task.project_id, - task.title, - task.description, - task.status.value, - task.priority, - task.workflow_step, - task.requires_mcp, - task.depends_on, - ), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_task(self, task_id: int) -> Optional[Task]: - """Get task by ID. - - Args: - task_id: Task ID - - Returns: - Task object or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)) - row = cursor.fetchone() - return self._row_to_task(row) if row else None - - - - def update_task(self, task_id: int, updates: Dict[str, Any]) -> int: - """Update task fields. - - Args: - task_id: Task ID to update - updates: Dictionary of fields to update - - Returns: - Number of rows affected - - Raises: - ValueError: If any update key is not in the allowed fields whitelist - """ - if not updates: - return 0 - - # Validate all keys against whitelist to prevent SQL injection - invalid_fields = set(updates.keys()) - ALLOWED_TASK_FIELDS - if invalid_fields: - raise ValueError( - f"Invalid task fields: {invalid_fields}. " - f"Allowed fields: {ALLOWED_TASK_FIELDS}" - ) - - fields = [] - values = [] - for key, value in updates.items(): - # Safe to use key here since it's been validated against whitelist - fields.append(f"{key} = ?") - # Handle enum values - if isinstance(value, TaskStatus): - values.append(value.value) - else: - values.append(value) - - values.append(task_id) - - query = f"UPDATE tasks SET {', '.join(fields)} WHERE id = ?" - - if self._sync_lock is not None: - with self._sync_lock: - cursor = self.conn.cursor() - cursor.execute(query, values) - self.conn.commit() - return cursor.rowcount - else: - cursor = self.conn.cursor() - cursor.execute(query, values) - self.conn.commit() - return cursor.rowcount - - - - def create_task_with_issue( - self, - project_id: int, - issue_id: int, - task_number: str, - parent_issue_number: str, - title: str, - description: str, - status: TaskStatus, - priority: int, - workflow_step: int, - can_parallelize: bool, - requires_mcp: bool = False, - estimated_hours: float | None = None, - complexity_score: int | None = None, - uncertainty_level: str | None = None, - resource_requirements: str | None = None, - ) -> int: - """Create a new task with issue relationship. - - Args: - project_id: Project ID - issue_id: Parent issue ID - task_number: Hierarchical task number (e.g., "1.5.1", "2.3.2") - parent_issue_number: Parent issue number (e.g., "1.5") - title: Task title - description: Task description - status: Task status - priority: Task priority (0-4, 0 = highest) - workflow_step: Workflow step (1-15) - can_parallelize: Whether task can run in parallel - requires_mcp: Whether task requires MCP tools - estimated_hours: Time estimate in hours (optional) - complexity_score: Complexity rating 1-5 (optional) - uncertainty_level: "low", "medium", "high" (optional) - resource_requirements: JSON string of required skills/tools (optional) - - Returns: - Task ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO tasks ( - project_id, issue_id, task_number, parent_issue_number, - title, description, status, priority, workflow_step, - can_parallelize, requires_mcp, - estimated_hours, complexity_score, uncertainty_level, resource_requirements - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - project_id, - issue_id, - task_number, - parent_issue_number, - title, - description, - status.value, - priority, - workflow_step, - can_parallelize, - requires_mcp, - estimated_hours, - complexity_score, - uncertainty_level, - resource_requirements, - ), - ) - self.conn.commit() - return cursor.lastrowid - - async def get_tasks_by_issue(self, issue_id: int) -> List[Task]: - """Get all tasks for an issue. - - Args: - issue_id: Issue ID - - Returns: - List of Task objects ordered by task_number - - Note: - Uses async connection with automatic health check and reconnection. - Call close_async() when done to release database resources. - """ - conn = await self._get_async_conn() - - async with conn.execute( - "SELECT * FROM tasks WHERE issue_id = ? ORDER BY task_number", - (issue_id,), - ) as cursor: - rows = await cursor.fetchall() - return [self._row_to_task(row) for row in rows] - - - - def get_tasks_by_parent_issue_number(self, parent_issue_number: str) -> List[Task]: - """Get all tasks by parent issue number. - - Args: - parent_issue_number: Parent issue number (e.g., "1.5") - - Returns: - List of Task objects - """ - cursor = self.conn.cursor() - cursor.execute( - "SELECT * FROM tasks WHERE parent_issue_number = ? ORDER BY task_number", - (parent_issue_number,), - ) - rows = cursor.fetchall() - return [self._row_to_task(row) for row in rows] - - - - def get_pending_tasks(self, project_id: int, limit: int = 5) -> List[Dict[str, Any]]: - """Get next pending tasks for next actions queue. - - Args: - project_id: Project ID - limit: Maximum number of tasks to return - - Returns: - Prioritized list with keys: id, title, priority, created_at - (Ordered by priority: 0=Critical, 1=High, 2=Medium, 3=Low, 4=Nice-to-have) - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT id, title, priority, created_at - FROM tasks - WHERE project_id = ? AND status = 'pending' - ORDER BY priority ASC, created_at ASC - LIMIT ? - """, - (project_id, limit), - ) - return [dict(row) for row in cursor.fetchall()] - - def add_task_dependency(self, task_id: int, depends_on_task_id: int) -> None: - """Add a dependency relationship between tasks. - - Args: - task_id: The task that depends on another - depends_on_task_id: The task that must be completed first - - Raises: - sqlite3.IntegrityError: If dependency would create a cycle - """ - cursor = self.conn.cursor() - - # Insert into junction table - cursor.execute( - """ - INSERT INTO task_dependencies (task_id, depends_on_task_id) - VALUES (?, ?) - """, - (task_id, depends_on_task_id), - ) - - # Update depends_on JSON array in tasks table - cursor.execute("SELECT depends_on FROM tasks WHERE id = ?", (task_id,)) - row = cursor.fetchone() - - if row and row[0]: - depends_on = json.loads(row[0]) if row[0] else [] - else: - depends_on = [] - - if depends_on_task_id not in depends_on: - depends_on.append(depends_on_task_id) - - cursor.execute( - """ - UPDATE tasks SET depends_on = ? WHERE id = ? - """, - (json.dumps(depends_on), task_id), - ) - - self.conn.commit() - - - - def get_task_dependencies(self, task_id: int) -> list: - """Get all tasks that the given task depends on. - - Args: - task_id: The task ID to get dependencies for - - Returns: - List of task IDs that must be completed first - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT depends_on_task_id - FROM task_dependencies - WHERE task_id = ? - """, - (task_id,), - ) - - return [row[0] for row in cursor.fetchall()] - - - - def _row_to_task(self, row: Union[sqlite3.Row, aiosqlite.Row]) -> Task: - """Convert a database row to a Task object. - - Args: - row: SQLite Row object from tasks table (sync or async) - - Returns: - Task dataclass instance - - Note: - Both sqlite3.Row and aiosqlite.Row support dictionary-style access - via row["column_name"], which this method relies on. - """ - row_id = row["id"] - - # Parse timestamps - created_at should never be NULL (enforced by schema) - created_at = self._parse_datetime(row["created_at"], "created_at", row_id) - if created_at is None: - raise ValueError( - f"Task {row_id} has NULL created_at - database integrity issue. " - "The schema enforces NOT NULL on created_at." - ) - completed_at = self._parse_datetime(row["completed_at"], "completed_at", row_id) - - # Convert status string to enum - status = TaskStatus.PENDING - if row["status"]: - try: - status = TaskStatus(row["status"]) - except ValueError: - logger.warning( - f"Invalid task status '{row['status']}' for task {row_id}, defaulting to PENDING" - ) - - # Parse effort estimation fields (handle missing columns for backward compatibility) - estimated_hours = None - complexity_score = None - uncertainty_level = None - resource_requirements = None - - try: - estimated_hours = row["estimated_hours"] - except (KeyError, IndexError): - pass - - try: - complexity_score = row["complexity_score"] - except (KeyError, IndexError): - pass - - try: - uncertainty_level = row["uncertainty_level"] - except (KeyError, IndexError): - pass - - try: - resource_requirements = row["resource_requirements"] - except (KeyError, IndexError): - pass - - # Parse intervention_context JSON field (handle missing column for backward compat) - intervention_context = None - try: - raw_ctx = row["intervention_context"] - if raw_ctx: - intervention_context = json.loads(raw_ctx) if isinstance(raw_ctx, str) else raw_ctx - except (KeyError, IndexError): - pass - except (json.JSONDecodeError, TypeError) as e: - logger.warning( - f"Invalid intervention_context JSON for task {row_id}: {e}" - ) - - return Task( - id=row_id, - project_id=row["project_id"], - issue_id=row["issue_id"], - task_number=row["task_number"] or "", - parent_issue_number=row["parent_issue_number"] or "", - title=row["title"] or "", - description=row["description"] or "", - status=status, - assigned_to=row["assigned_to"], - depends_on=row["depends_on"] or "", - can_parallelize=bool(row["can_parallelize"]), - priority=row["priority"] if row["priority"] is not None else 2, - workflow_step=row["workflow_step"] if row["workflow_step"] is not None else 1, - requires_mcp=bool(row["requires_mcp"]), - estimated_tokens=row["estimated_tokens"] if row["estimated_tokens"] is not None else 0, - actual_tokens=row["actual_tokens"], - estimated_hours=estimated_hours, - complexity_score=complexity_score, - uncertainty_level=uncertainty_level, - resource_requirements=resource_requirements, - intervention_context=intervention_context, - created_at=created_at, - completed_at=completed_at, - ) - - - - def get_dependent_tasks(self, task_id: int) -> list: - """Get all tasks that depend on the given task. - - Args: - task_id: The task ID to find dependents for - - Returns: - List of task IDs that depend on this task - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT task_id - FROM task_dependencies - WHERE depends_on_task_id = ? - """, - (task_id,), - ) - - return [row[0] for row in cursor.fetchall()] - - - - def remove_task_dependency(self, task_id: int, depends_on_task_id: int) -> None: - """Remove a dependency relationship between tasks. - - Args: - task_id: The task that currently depends on another - depends_on_task_id: The task dependency to remove - """ - cursor = self.conn.cursor() - - # Remove from junction table - cursor.execute( - """ - DELETE FROM task_dependencies - WHERE task_id = ? AND depends_on_task_id = ? - """, - (task_id, depends_on_task_id), - ) - - # Update depends_on JSON array in tasks table - cursor.execute("SELECT depends_on FROM tasks WHERE id = ?", (task_id,)) - row = cursor.fetchone() - - if row and row[0]: - depends_on = json.loads(row[0]) if row[0] else [] - if depends_on_task_id in depends_on: - depends_on.remove(depends_on_task_id) - - cursor.execute( - """ - UPDATE tasks SET depends_on = ? WHERE id = ? - """, - (json.dumps(depends_on), task_id), - ) - - self.conn.commit() - - - - def clear_all_task_dependencies(self, task_id: int) -> None: - """Remove all dependencies for a given task. - - Args: - task_id: The task ID to clear dependencies for - """ - cursor = self.conn.cursor() - - # Remove from junction table - cursor.execute( - """ - DELETE FROM task_dependencies WHERE task_id = ? - """, - (task_id,), - ) - - # Clear depends_on JSON array - cursor.execute( - """ - UPDATE tasks SET depends_on = '[]' WHERE id = ? - """, - (task_id,), - ) - - self.conn.commit() - - - - def update_task_commit_sha(self, task_id: int, commit_sha: str) -> None: - """Update task with git commit SHA. - - Args: - task_id: Task ID - commit_sha: Git commit hash - """ - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE tasks SET commit_sha = ? WHERE id = ? - """, - (commit_sha, task_id), - ) - self.conn.commit() - - def update_task_intervention_context( - self, task_id: int, context: Dict[str, Any] - ) -> None: - """Update task with supervisor intervention context. - - This is used when the supervisor detects a tactical pattern match - and needs to provide context to the agent for retry. - - Args: - task_id: Task ID - context: Intervention context dictionary with structure: - { - "intervention_applied": bool, - "pattern_matched": str, - "existing_files": List[str], - "instruction": str, - "strategy": str - } - """ - import json - - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE tasks SET intervention_context = ? WHERE id = ? - """, - (json.dumps(context), task_id), - ) - self.conn.commit() - - def get_task_intervention_context( - self, task_id: int - ) -> Optional[Dict[str, Any]]: - """Get intervention context for a task. - - Args: - task_id: Task ID - - Returns: - Intervention context dictionary or None if not set - """ - import json - - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT intervention_context FROM tasks WHERE id = ? - """, - (task_id,), - ) - row = cursor.fetchone() - if row and row[0]: - return json.loads(row[0]) - return None - - def clear_task_intervention_context(self, task_id: int) -> None: - """Clear intervention context for a task. - - This is typically called after a task succeeds to clean up - the intervention state. - - Args: - task_id: Task ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE tasks SET intervention_context = NULL WHERE id = ? - """, - (task_id,), - ) - self.conn.commit() - - def get_task_by_commit(self, commit_sha: str) -> Optional[dict]: - """Find task by git commit SHA. - - Args: - commit_sha: Git commit hash (full or short) - - Returns: - Task dictionary or None if not found - """ - cursor = self.conn.cursor() - # Support both full (40 char) and short (7 char) hashes - cursor.execute( - """ - SELECT * FROM tasks - WHERE commit_sha = ? OR commit_sha LIKE ? - LIMIT 1 - """, - (commit_sha, f"{commit_sha}%"), - ) - row = cursor.fetchone() - return dict(row) if row else None - - - - def get_recently_completed_tasks( - self, project_id: int, limit: int = 10 - ) -> List[Dict[str, Any]]: - """Get recently completed tasks for session summary. - - Args: - project_id: Project ID - limit: Maximum number of tasks to return - - Returns: - List of dicts with keys: id, title, status, completed_at - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT id, title, status, completed_at - FROM tasks - WHERE project_id = ? AND status = 'completed' - ORDER BY completed_at DESC - LIMIT ? - """, - (project_id, limit), - ) - return [dict(row) for row in cursor.fetchall()] - - def get_tasks_by_agent( - self, agent_id: str, project_id: Optional[int] = None, limit: int = 100 - ) -> List[Task]: - """Get all tasks assigned to an agent. - - Used for calculating agent maturity metrics based on task history. - - Args: - agent_id: Agent ID to filter by (matches assigned_to field) - project_id: Optional project ID to filter by - limit: Maximum number of tasks to return (default: 100) - - Returns: - List of Task objects ordered by created_at DESC (most recent first) - """ - cursor = self.conn.cursor() - - if project_id is not None: - cursor.execute( - """ - SELECT * FROM tasks - WHERE assigned_to = ? AND project_id = ? - ORDER BY created_at DESC - LIMIT ? - """, - (agent_id, project_id, limit), - ) - else: - cursor.execute( - """ - SELECT * FROM tasks - WHERE assigned_to = ? - ORDER BY created_at DESC - LIMIT ? - """, - (agent_id, limit), - ) - - rows = cursor.fetchall() - return [self._row_to_task(row) for row in rows] - - async def get_tasks_by_agent_async( - self, agent_id: str, project_id: Optional[int] = None, limit: int = 100 - ) -> List[Task]: - """Get all tasks assigned to an agent (async version). - - Used for calculating agent maturity metrics based on task history. - This async version uses aiosqlite for non-blocking database access. - - Args: - agent_id: Agent ID to filter by (matches assigned_to field) - project_id: Optional project ID to filter by - limit: Maximum number of tasks to return (default: 100) - - Returns: - List of Task objects ordered by created_at DESC (most recent first) - - Note: - Uses async connection with automatic health check and reconnection. - Leverages idx_tasks_assigned_to index for optimal query performance. - """ - conn = await self._get_async_conn() - - if project_id is not None: - async with conn.execute( - """ - SELECT * FROM tasks - WHERE assigned_to = ? AND project_id = ? - ORDER BY created_at DESC - LIMIT ? - """, - (agent_id, project_id, limit), - ) as cursor: - rows = await cursor.fetchall() - return [self._row_to_task(row) for row in rows] - else: - async with conn.execute( - """ - SELECT * FROM tasks - WHERE assigned_to = ? - ORDER BY created_at DESC - LIMIT ? - """, - (agent_id, limit), - ) as cursor: - rows = await cursor.fetchall() - return [self._row_to_task(row) for row in rows] - - # Evidence Storage (Evidence-Based Quality Enforcement) - - def save_task_evidence(self, task_id: int, evidence: "Evidence", commit: bool = True) -> int: - """Save task evidence to database. - - Args: - task_id: Task ID - evidence: Evidence object from EvidenceVerifier - commit: Whether to commit immediately (default: True) - - Returns: - Evidence record ID - """ - # Import here to avoid circular dependencies - from codeframe.enforcement.evidence_verifier import Evidence # noqa: F401 - from codeframe.enforcement.adaptive_test_runner import TestResult # noqa: F401 - from codeframe.enforcement.skip_pattern_detector import SkipViolation # noqa: F401 - from codeframe.enforcement.quality_tracker import QualityMetrics # noqa: F401 - - # Validate evidence data before storage - if not (0 <= evidence.test_result.pass_rate <= 100): - raise ValueError( - f"Invalid pass_rate: {evidence.test_result.pass_rate} (must be 0-100)" - ) - - if evidence.test_result.coverage is not None and not ( - 0 <= evidence.test_result.coverage <= 100 - ): - raise ValueError( - f"Invalid coverage: {evidence.test_result.coverage} (must be 0-100)" - ) - - # Validate test count consistency - total_calculated = ( - evidence.test_result.passed_tests - + evidence.test_result.failed_tests - + evidence.test_result.skipped_tests - ) - if evidence.test_result.total_tests != total_calculated: - raise ValueError( - f"Test count mismatch: total_tests={evidence.test_result.total_tests}, " - f"but passed+failed+skipped={total_calculated}" - ) - - cursor = self.conn.cursor() - - # Serialize skip violations to JSON with error handling - try: - skip_violations_json = json.dumps([ - { - "file": v.file, - "line": v.line, - "pattern": v.pattern, - "context": v.context - } - for v in evidence.skip_violations - ]) - except (TypeError, ValueError) as e: - raise ValueError(f"Failed to serialize skip violations to JSON: {e}") from e - - # Serialize quality metrics to JSON with error handling - try: - quality_metrics_json = json.dumps({ - "timestamp": evidence.quality_metrics.timestamp, - "response_count": evidence.quality_metrics.response_count, - "test_pass_rate": evidence.quality_metrics.test_pass_rate, - "coverage_percentage": evidence.quality_metrics.coverage_percentage, - "total_tests": evidence.quality_metrics.total_tests, - "passed_tests": evidence.quality_metrics.passed_tests, - "failed_tests": evidence.quality_metrics.failed_tests, - "language": evidence.quality_metrics.language, - "framework": evidence.quality_metrics.framework, - }) - except (TypeError, ValueError) as e: - raise ValueError(f"Failed to serialize quality metrics to JSON: {e}") from e - - # Serialize verification errors - verification_errors = "\n".join(evidence.verification_errors) if evidence.verification_errors else None - - cursor.execute( - """ - INSERT INTO task_evidence ( - task_id, agent_id, language, framework, - total_tests, passed_tests, failed_tests, skipped_tests, - pass_rate, coverage, test_output, - skip_violations_count, skip_violations_json, skip_check_passed, - quality_metrics_json, - verified, verification_errors, - timestamp, task_description - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - task_id, - evidence.agent_id, - evidence.language, - evidence.framework, - evidence.test_result.total_tests, - evidence.test_result.passed_tests, - evidence.test_result.failed_tests, - evidence.test_result.skipped_tests, - evidence.test_result.pass_rate, - evidence.test_result.coverage, - evidence.test_output, - len(evidence.skip_violations), - skip_violations_json, - evidence.skip_check_passed, - quality_metrics_json, - evidence.verified, - verification_errors, - evidence.timestamp, - evidence.task_description, - ), - ) - if commit: - self.conn.commit() - return cursor.lastrowid - - def get_task_evidence(self, task_id: int) -> Optional["Evidence"]: - """Get latest evidence for a task. - - Args: - task_id: Task ID - - Returns: - Evidence object or None if not found - """ - # Import here to avoid circular dependencies - from codeframe.enforcement.evidence_verifier import Evidence # noqa: F401 - from codeframe.enforcement.adaptive_test_runner import TestResult # noqa: F401 - from codeframe.enforcement.skip_pattern_detector import SkipViolation # noqa: F401 - from codeframe.enforcement.quality_tracker import QualityMetrics # noqa: F401 - - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT * FROM task_evidence - WHERE task_id = ? - ORDER BY created_at DESC - LIMIT 1 - """, - (task_id,), - ) - row = cursor.fetchone() - - if not row: - return None - - return self._row_to_evidence(row) - - def get_task_evidence_history(self, task_id: int, limit: int = 10) -> List["Evidence"]: - """Get evidence history for a task (for audit trail). - - Args: - task_id: Task ID - limit: Maximum number of records to return - - Returns: - List of Evidence objects ordered by created_at DESC - """ - # Import here to avoid circular dependencies - from codeframe.enforcement.evidence_verifier import Evidence # noqa: F401 - - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT * FROM task_evidence - WHERE task_id = ? - ORDER BY created_at DESC - LIMIT ? - """, - (task_id, limit), - ) - rows = cursor.fetchall() - - return [self._row_to_evidence(row) for row in rows] - - def _row_to_evidence(self, row: sqlite3.Row) -> "Evidence": - """Convert database row to Evidence object. - - Args: - row: SQLite Row from task_evidence table - - Returns: - Evidence object - - Raises: - ValueError: If JSON data fails validation - """ - # Import here to avoid circular dependencies - from codeframe.enforcement.evidence_verifier import Evidence - from codeframe.enforcement.adaptive_test_runner import TestResult - from codeframe.enforcement.skip_pattern_detector import SkipViolation - from codeframe.enforcement.quality_tracker import QualityMetrics - - # Deserialize and validate skip violations (defense in depth) - skip_violations_data = json.loads(row["skip_violations_json"]) if row["skip_violations_json"] else [] - - if skip_violations_data: - if not isinstance(skip_violations_data, list): - raise ValueError("skip_violations_json must be a list") - for v in skip_violations_data: - if not isinstance(v, dict): - raise ValueError("Each skip violation must be a dict") - required_keys = {"file", "line", "pattern", "context"} - if not required_keys.issubset(v.keys()): - raise ValueError(f"Skip violation missing required keys: {required_keys - v.keys()}") - - skip_violations = [ - SkipViolation( - file=v["file"], - line=v["line"], - pattern=v["pattern"], - context=v["context"] - ) - for v in skip_violations_data - ] - - # Deserialize and validate quality metrics (defense in depth) - quality_metrics_data = json.loads(row["quality_metrics_json"]) - - if not isinstance(quality_metrics_data, dict): - raise ValueError("quality_metrics_json must be a dict") - required_metrics_keys = { - "timestamp", "response_count", "test_pass_rate", "coverage_percentage", - "total_tests", "passed_tests", "failed_tests", "language" - } - if not required_metrics_keys.issubset(quality_metrics_data.keys()): - raise ValueError( - f"Quality metrics missing required keys: {required_metrics_keys - quality_metrics_data.keys()}" - ) - - quality_metrics = QualityMetrics( - timestamp=quality_metrics_data["timestamp"], - response_count=quality_metrics_data["response_count"], - test_pass_rate=quality_metrics_data["test_pass_rate"], - coverage_percentage=quality_metrics_data["coverage_percentage"], - total_tests=quality_metrics_data["total_tests"], - passed_tests=quality_metrics_data["passed_tests"], - failed_tests=quality_metrics_data["failed_tests"], - language=quality_metrics_data["language"], - framework=quality_metrics_data.get("framework"), - ) - - # Reconstruct test result - test_result = TestResult( - success=row["pass_rate"] == 100.0, - output=row["test_output"], - total_tests=row["total_tests"], - passed_tests=row["passed_tests"], - failed_tests=row["failed_tests"], - skipped_tests=row["skipped_tests"], - pass_rate=row["pass_rate"], - coverage=row["coverage"], - duration=0.0, # Duration not stored in evidence table - ) - - # Parse verification errors - verification_errors = ( - row["verification_errors"].split("\n") - if row["verification_errors"] - else [] - ) - - return Evidence( - test_result=test_result, - test_output=row["test_output"], - skip_violations=skip_violations, - skip_check_passed=bool(row["skip_check_passed"]), - quality_metrics=quality_metrics, - timestamp=row["timestamp"], - language=row["language"], - framework=row["framework"], - agent_id=row["agent_id"], - task_description=row["task_description"], - verified=bool(row["verified"]), - verification_errors=verification_errors if verification_errors else None, - ) - - def delete_all_project_tasks(self, project_id: int, cursor: sqlite3.Cursor = None) -> int: - """Delete all tasks for a project, handling FK constraints. - - This method deletes all dependent records before deleting tasks: - - task_dependencies (both task_id and depends_on_task_id) - - test_results - - correction_attempts - - Note: code_reviews and task_evidence have ON DELETE CASCADE and are handled - automatically by the database. - - Args: - project_id: Project ID to delete tasks for - cursor: Optional cursor for transaction support. If provided, - the caller is responsible for commit/rollback. - - Returns: - Number of tasks deleted - """ - own_cursor = cursor is None - if own_cursor: - cursor = self.conn.cursor() - - try: - # Get all task IDs for this project - cursor.execute( - "SELECT id FROM tasks WHERE project_id = ?", - (project_id,), - ) - task_ids = [row[0] for row in cursor.fetchall()] - - if not task_ids: - return 0 - - # Create placeholders for IN clause - placeholders = ",".join("?" * len(task_ids)) - - # Delete from task_dependencies (both FK columns) - cursor.execute( - f"DELETE FROM task_dependencies WHERE task_id IN ({placeholders}) OR depends_on_task_id IN ({placeholders})", - task_ids + task_ids, - ) - - # Delete from test_results - cursor.execute( - f"DELETE FROM test_results WHERE task_id IN ({placeholders})", - task_ids, - ) - - # Delete from correction_attempts - cursor.execute( - f"DELETE FROM correction_attempts WHERE task_id IN ({placeholders})", - task_ids, - ) - - # Now delete the tasks (code_reviews and task_evidence cascade automatically) - cursor.execute( - "DELETE FROM tasks WHERE project_id = ?", - (project_id,), - ) - task_count = cursor.rowcount - - if own_cursor: - self.conn.commit() - - return task_count - - except Exception: - if own_cursor: - self.conn.rollback() - raise - - # Code Review CRUD operations (Sprint 10: 015-review-polish) - diff --git a/codeframe/persistence/repositories/test_repository.py b/codeframe/persistence/repositories/test_repository.py deleted file mode 100644 index f833577e..00000000 --- a/codeframe/persistence/repositories/test_repository.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Repository for Test Repository operations. - -Extracted from monolithic Database class for better maintainability. -""" - -from typing import List, Optional, Dict, Any -import logging - - -from codeframe.persistence.repositories.base import BaseRepository - -logger = logging.getLogger(__name__) - - -class TestRepository(BaseRepository): - """Repository for test repository operations.""" - - def create_test_result( - self, - task_id: int, - status: str, - passed: int = 0, - failed: int = 0, - errors: int = 0, - skipped: int = 0, - duration: float = 0.0, - output: Optional[str] = None, - ) -> int: - """Create a test result record. - - Args: - task_id: Task ID this result belongs to - status: Test status (passed, failed, error, timeout, no_tests) - passed: Number of tests that passed - failed: Number of tests that failed - errors: Number of tests with errors - skipped: Number of tests skipped - duration: Test execution duration in seconds - output: Raw test output (JSON string or plain text) - - Returns: - Test result ID - """ - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO test_results ( - task_id, status, passed, failed, errors, skipped, duration, output - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - (task_id, status, passed, failed, errors, skipped, duration, output), - ) - self.conn.commit() - return cursor.lastrowid - - - - def get_test_results_by_task(self, task_id: int) -> List[Dict[str, Any]]: - """Get all test results for a task. - - Args: - task_id: Task ID - - Returns: - List of test result dictionaries ordered by created_at (newest first) - """ - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT * FROM test_results - WHERE task_id = ? - ORDER BY created_at DESC - """, - (task_id,), - ) - rows = cursor.fetchall() - return [dict(row) for row in rows] - - # Correction Attempts Methods (cf-43: Self-Correction Loop) - diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 7f5152a4..dc391e96 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -135,35 +135,6 @@ def api_client(class_temp_db_path: Path) -> Generator[TestClient, None, None]: # Add authentication header to all requests client.headers["Authorization"] = f"Bearer {test_token}" - # Patch Database.create_project to inject user_id=1 when not provided - original_create_project = db.create_project - - def patched_create_project( - name: str, - description: str, - source_type: str = "empty", - source_location: str = None, - source_branch: str = "main", - workspace_path: str = None, - user_id: int = None, - **kwargs, - ) -> int: - # Default to admin user (id=1) if no user_id provided - if user_id is None: - user_id = 1 - return original_create_project( - name=name, - description=description, - source_type=source_type, - source_location=source_location, - source_branch=source_branch, - workspace_path=workspace_path, - user_id=user_id, - **kwargs, - ) - - db.create_project = patched_create_project - yield client # Restore original environment state diff --git a/tests/api/test_api_issues.py b/tests/api/test_api_issues.py deleted file mode 100644 index 83b4d3d6..00000000 --- a/tests/api/test_api_issues.py +++ /dev/null @@ -1,376 +0,0 @@ -"""Tests for Issues/Tasks API endpoint (cf-26). - -Sprint 2 Foundation Contract: -GET /api/projects/{id}/issues?include=tasks → IssuesResponse - -Tests follow RED-GREEN-REFACTOR TDD cycle. -""" - -import pytest -from datetime import datetime - -from codeframe.core.models import TaskStatus, Issue - - -def get_app(): - """Get the current app instance after module reload.""" - from codeframe.ui.server import app - - return app - - -@pytest.fixture(scope="function") -def project_with_issues(api_client): - """Create test project with issues and tasks. - - Args: - api_client: FastAPI test client from class-scoped fixture - - Returns: - Tuple of (project_id, issues, tasks) - """ - # Create project - project_id = get_app().state.db.create_project( - name="Test Issues Project", description="Test Issues Project project" - ) - - # Create issues - issue1_id = get_app().state.db.create_issue( - Issue( - project_id=project_id, - issue_number="1.1", - title="Implement authentication", - description="Add user login and JWT", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=5, - ) - ) - - issue2_id = get_app().state.db.create_issue( - Issue( - project_id=project_id, - issue_number="1.2", - title="Setup database schema", - description="Create initial migrations", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=3, - ) - ) - - # Create tasks for issue 1 - task1_id = get_app().state.db.create_task_with_issue( - project_id=project_id, - issue_id=issue1_id, - task_number="1.1.1", - parent_issue_number="1.1", - title="Create User model", - description="Define User schema", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=5, - can_parallelize=False, - requires_mcp=False, - ) - - task2_id = get_app().state.db.create_task_with_issue( - project_id=project_id, - issue_id=issue1_id, - task_number="1.1.2", - parent_issue_number="1.1", - title="Implement JWT generation", - description="Add JWT token creation", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=5, - can_parallelize=False, - requires_mcp=False, - ) - - return project_id, [issue1_id, issue2_id], [task1_id, task2_id] - - -class TestIssuesEndpointBasics: - """Test basic Issues endpoint functionality.""" - - def test_issues_endpoint_exists(self, api_client, project_with_issues): - """Test that GET /api/projects/{id}/issues endpoint exists.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - - # Should not return 404 - assert response.status_code != 404 - - def test_issues_endpoint_returns_json(self, api_client, project_with_issues): - """Test that Issues endpoint returns JSON response.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - - assert response.headers["content-type"] == "application/json" - - def test_issues_endpoint_returns_200(self, api_client, project_with_issues): - """Test that Issues endpoint returns 200.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - - assert response.status_code == 200 - - -class TestIssuesResponseStructure: - """Test Issues response structure matches API contract.""" - - def test_issues_response_has_required_fields(self, api_client, project_with_issues): - """Test that Issues response includes all required fields. - - Required fields (API Contract): - - issues: Issue[] - - total_issues: number - - total_tasks: number - - next_cursor?: string - - prev_cursor?: string - """ - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - # Verify all required fields present - assert "issues" in data - assert "total_issues" in data - assert "total_tasks" in data - - # Optional cursor fields - # They may or may not be present depending on pagination - - def test_issues_response_contains_issues_array(self, api_client, project_with_issues): - """Test that issues field is an array.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - assert isinstance(data["issues"], list) - assert len(data["issues"]) == 2 # We created 2 issues - - def test_issues_response_total_counts(self, api_client, project_with_issues): - """Test that total counts are correct.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - assert data["total_issues"] == 2 - assert data["total_tasks"] == 2 # We created 2 tasks - - -class TestIssueStructure: - """Test individual Issue structure matches API contract.""" - - def test_issue_has_required_fields(self, api_client, project_with_issues): - """Test that each Issue has all required fields. - - Required fields (API Contract): - - id: string - - issue_number: string - - title: string - - description: string - - status: WorkStatus - - priority: number - - depends_on: string[] - - proposed_by: 'agent' | 'human' - - created_at: ISODate - - updated_at: ISODate - - completed_at: ISODate | null - """ - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - issue = data["issues"][0] - - # Verify all required fields - assert "id" in issue - assert "issue_number" in issue - assert "title" in issue - assert "description" in issue - assert "status" in issue - assert "priority" in issue - assert "depends_on" in issue - assert "proposed_by" in issue - assert "created_at" in issue - assert "updated_at" in issue - assert "completed_at" in issue - - def test_issue_id_is_string(self, api_client, project_with_issues): - """Test that issue id is returned as string (not int).""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - issue = data["issues"][0] - assert isinstance(issue["id"], str) - - def test_issue_depends_on_is_array(self, api_client, project_with_issues): - """Test that depends_on is an array.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - issue = data["issues"][0] - assert isinstance(issue["depends_on"], list) - - def test_issue_proposed_by_is_valid(self, api_client, project_with_issues): - """Test that proposed_by is either 'agent' or 'human'.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - issue = data["issues"][0] - assert issue["proposed_by"] in ["agent", "human"] - - def test_issue_timestamps_are_rfc3339(self, api_client, project_with_issues): - """Test that timestamps follow RFC 3339 format with timezone.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - issue = data["issues"][0] - - # Verify created_at is valid RFC 3339 - created_at = issue["created_at"] - assert isinstance(created_at, str) - dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) - assert dt.tzinfo is not None - - # Verify updated_at is valid RFC 3339 - updated_at = issue["updated_at"] - assert isinstance(updated_at, str) - dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) - assert dt.tzinfo is not None - - -class TestIssuesWithTasks: - """Test Issues endpoint with ?include=tasks query param.""" - - def test_issues_include_tasks_query_param(self, api_client, project_with_issues): - """Test that ?include=tasks query param includes tasks in response.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues?include=tasks") - data = response.json() - - # First issue should have tasks - issue = data["issues"][0] - assert "tasks" in issue - - def test_issue_tasks_is_array(self, api_client, project_with_issues): - """Test that tasks field is an array when included.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues?include=tasks") - data = response.json() - - issue = data["issues"][0] - assert isinstance(issue["tasks"], list) - - def test_issue_tasks_count(self, api_client, project_with_issues): - """Test that first issue has correct number of tasks.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues?include=tasks") - data = response.json() - - # First issue should have 2 tasks - issue = data["issues"][0] - assert len(issue["tasks"]) == 2 - - -class TestTaskStructure: - """Test individual Task structure matches API contract.""" - - def test_task_has_required_fields(self, api_client, project_with_issues): - """Test that each Task has all required fields. - - Required fields (API Contract): - - id: string - - task_number: string - - title: string - - description: string - - status: WorkStatus - - depends_on: string[] - - proposed_by: 'agent' | 'human' - - created_at: ISODate - - updated_at: ISODate - - completed_at: ISODate | null - """ - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues?include=tasks") - data = response.json() - - task = data["issues"][0]["tasks"][0] - - # Verify all required fields - assert "id" in task - assert "task_number" in task - assert "title" in task - assert "description" in task - assert "status" in task - assert "depends_on" in task - assert "proposed_by" in task - assert "created_at" in task - assert "updated_at" in task - assert "completed_at" in task - - def test_task_id_is_string(self, api_client, project_with_issues): - """Test that task id is returned as string (not int).""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues?include=tasks") - data = response.json() - - task = data["issues"][0]["tasks"][0] - assert isinstance(task["id"], str) - - def test_task_depends_on_is_array(self, api_client, project_with_issues): - """Test that task depends_on is an array.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues?include=tasks") - data = response.json() - - task = data["issues"][0]["tasks"][0] - assert isinstance(task["depends_on"], list) - - -class TestIssuesEndpointEdgeCases: - """Test Issues endpoint edge cases and error handling.""" - - def test_issues_without_tasks_query_param(self, api_client, project_with_issues): - """Test that tasks field is not included without ?include=tasks.""" - project_id, _, _ = project_with_issues - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - # Tasks should not be included - issue = data["issues"][0] - assert "tasks" not in issue - - def test_empty_issues_list(self, api_client): - """Test that empty project returns empty issues array.""" - # Create project without issues - project_id = get_app().state.db.create_project( - name="Empty Project", description="Empty Project project" - ) - - response = api_client.get(f"/api/projects/{project_id}/issues") - data = response.json() - - assert data["issues"] == [] - assert data["total_issues"] == 0 - assert data["total_tasks"] == 0 - - def test_nonexistent_project_returns_404(self, api_client): - """Test that nonexistent project returns 404.""" - response = api_client.get("/api/projects/99999/issues") - - assert response.status_code == 404 - - def test_issues_endpoint_handles_invalid_project_id(self, api_client): - """Test that endpoint handles invalid project ID gracefully.""" - response = api_client.get("/api/projects/invalid/issues") - - # Should return 422 (validation error) or 404 - assert response.status_code in [422, 404] diff --git a/tests/api/test_api_metrics.py b/tests/api/test_api_metrics.py deleted file mode 100644 index a4008b8f..00000000 --- a/tests/api/test_api_metrics.py +++ /dev/null @@ -1,629 +0,0 @@ -"""Tests for Metrics API endpoints (Sprint 10 Phase 5: T127-T129). - -Sprint 10 - Phase 5: Metrics & Cost Tracking - -Tests follow RED-GREEN-REFACTOR TDD cycle. - -Endpoints tested: -- GET /api/projects/{id}/metrics/tokens (T127) -- GET /api/projects/{id}/metrics/costs (T128) -- GET /api/agents/{agent_id}/metrics (T129) -""" - -import pytest -from datetime import datetime, timezone, timedelta -from codeframe.core.models import TokenUsage, CallType - - -def get_app(): - """Get the current app instance after module reload.""" - from codeframe.ui.server import app - - return app - - -@pytest.fixture(scope="function") -def project_with_token_usage(api_client): - """Create test project with token usage records. - - Args: - api_client: FastAPI test client from class-scoped fixture - - Returns: - Tuple of (project_id, usage_ids) - """ - # Create project - project_id = get_app().state.db.create_project( - name="Test Metrics Project", description="Test project for metrics API" - ) - - # Create token usage records (task_id=None is allowed) - now = datetime.now(timezone.utc) - yesterday = now - timedelta(days=1) - two_days_ago = now - timedelta(days=2) - - usage_ids = [] - - # Record 1: backend-001, Sonnet 4.5, task execution - usage1 = TokenUsage( - task_id=None, # No task association required - agent_id="backend-001", - project_id=project_id, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - estimated_cost_usd=0.0105, # (1000 * 3.00 + 500 * 15.00) / 1M = 0.0105 - call_type=CallType.TASK_EXECUTION, - timestamp=now, - ) - usage_ids.append(get_app().state.db.save_token_usage(usage1)) - - # Record 2: backend-001, Sonnet 4.5, code review - usage2 = TokenUsage( - task_id=None, - agent_id="backend-001", - project_id=project_id, - model_name="claude-sonnet-4-5", - input_tokens=500, - output_tokens=250, - estimated_cost_usd=0.00525, # (500 * 3.00 + 250 * 15.00) / 1M = 0.00525 - call_type=CallType.CODE_REVIEW, - timestamp=yesterday, - ) - usage_ids.append(get_app().state.db.save_token_usage(usage2)) - - # Record 3: review-001, Haiku 4, code review (older) - usage3 = TokenUsage( - task_id=None, - agent_id="review-001", - project_id=project_id, - model_name="claude-haiku-4", - input_tokens=2000, - output_tokens=1000, - estimated_cost_usd=0.0056, # (2000 * 0.80 + 1000 * 4.00) / 1M = 0.0056 - call_type=CallType.CODE_REVIEW, - timestamp=two_days_ago, - ) - usage_ids.append(get_app().state.db.save_token_usage(usage3)) - - return project_id, usage_ids - - -class TestProjectTokenMetricsEndpoint: - """Test GET /api/projects/{id}/metrics/tokens endpoint (T127).""" - - def test_endpoint_exists(self, api_client, project_with_token_usage): - """Test that endpoint exists and returns 200.""" - project_id, _ = project_with_token_usage - response = api_client.get(f"/api/projects/{project_id}/metrics/tokens") - assert response.status_code == 200 - - def test_returns_all_usage_records(self, api_client, project_with_token_usage): - """Test that endpoint returns all token usage records.""" - project_id, usage_ids = project_with_token_usage - response = api_client.get(f"/api/projects/{project_id}/metrics/tokens") - - assert response.status_code == 200 - data = response.json() - - # Check structure - assert "project_id" in data - assert "total_tokens" in data - assert "total_calls" in data - assert "total_cost_usd" in data - assert "date_range" in data - assert "usage_records" in data - - # Check values - assert data["project_id"] == project_id - assert data["total_calls"] == 3 - assert data["total_tokens"] == 5250 # 1000+500 + 500+250 + 2000+1000 - assert abs(data["total_cost_usd"] - 0.02135) < 0.00001 # 0.0105 + 0.00525 + 0.0056 - - # Check usage records - assert len(data["usage_records"]) == 3 - - def test_date_filtering(self, api_client, project_with_token_usage): - """Test that date filtering works correctly.""" - project_id, _ = project_with_token_usage - - # Filter to only records from today (should get 1 record) - now = datetime.now(timezone.utc) - start_date = ( - now.replace(hour=0, minute=0, second=0, microsecond=0) - .isoformat() - .replace("+00:00", "Z") - ) - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens?start_date={start_date}" - ) - - assert response.status_code == 200 - data = response.json() - - # Should only get today's record - assert data["total_calls"] == 1 - assert len(data["usage_records"]) == 1 - - def test_invalid_date_format_returns_400(self, api_client, project_with_token_usage): - """Test that invalid date format returns 400 Bad Request.""" - project_id, _ = project_with_token_usage - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens?start_date=invalid-date" - ) - - assert response.status_code == 400 - assert "Invalid date format" in response.json()["detail"] - - def test_nonexistent_project_returns_404(self, api_client): - """Test that nonexistent project returns 404.""" - response = api_client.get("/api/projects/99999/metrics/tokens") - assert response.status_code == 404 - - -class TestProjectCostMetricsEndpoint: - """Test GET /api/projects/{id}/metrics/costs endpoint (T128).""" - - def test_endpoint_exists(self, api_client, project_with_token_usage): - """Test that endpoint exists and returns 200.""" - project_id, _ = project_with_token_usage - response = api_client.get(f"/api/projects/{project_id}/metrics/costs") - assert response.status_code == 200 - - def test_returns_cost_breakdown(self, api_client, project_with_token_usage): - """Test that endpoint returns cost breakdown by agent and model.""" - project_id, _ = project_with_token_usage - response = api_client.get(f"/api/projects/{project_id}/metrics/costs") - - assert response.status_code == 200 - data = response.json() - - # Check structure - assert "project_id" in data - assert "total_cost_usd" in data - assert "total_tokens" in data - assert "total_calls" in data - assert "by_agent" in data - assert "by_model" in data - - # Check values - assert data["project_id"] == project_id - assert data["total_calls"] == 3 - assert data["total_tokens"] == 5250 - assert abs(data["total_cost_usd"] - 0.02135) < 0.00001 - - # Check by_agent breakdown (2 agents) - assert len(data["by_agent"]) == 2 - - # Find backend-001 stats - backend_stats = next((a for a in data["by_agent"] if a["agent_id"] == "backend-001"), None) - assert backend_stats is not None - assert backend_stats["call_count"] == 2 - assert backend_stats["total_tokens"] == 2250 # 1000+500 + 500+250 - assert abs(backend_stats["cost_usd"] - 0.01575) < 0.00001 - - # Find review-001 stats - review_stats = next((a for a in data["by_agent"] if a["agent_id"] == "review-001"), None) - assert review_stats is not None - assert review_stats["call_count"] == 1 - assert review_stats["total_tokens"] == 3000 # 2000+1000 - assert abs(review_stats["cost_usd"] - 0.0056) < 0.00001 - - # Check by_model breakdown (2 models) - assert len(data["by_model"]) == 2 - - # Find Sonnet 4.5 stats - sonnet_stats = next( - (m for m in data["by_model"] if m["model_name"] == "claude-sonnet-4-5"), None - ) - assert sonnet_stats is not None - assert sonnet_stats["call_count"] == 2 - assert sonnet_stats["total_tokens"] == 2250 - - # Find Haiku 4 stats - haiku_stats = next( - (m for m in data["by_model"] if m["model_name"] == "claude-haiku-4"), None - ) - assert haiku_stats is not None - assert haiku_stats["call_count"] == 1 - assert haiku_stats["total_tokens"] == 3000 - - def test_nonexistent_project_returns_404(self, api_client): - """Test that nonexistent project returns 404.""" - response = api_client.get("/api/projects/99999/metrics/costs") - assert response.status_code == 404 - - -class TestAgentMetricsEndpoint: - """Test GET /api/agents/{agent_id}/metrics endpoint (T129).""" - - def test_endpoint_exists(self, api_client, project_with_token_usage): - """Test that endpoint exists and returns 200.""" - project_id, _ = project_with_token_usage - response = api_client.get("/api/agents/backend-001/metrics") - assert response.status_code == 200 - - def test_returns_agent_metrics(self, api_client, project_with_token_usage): - """Test that endpoint returns agent metrics across all projects.""" - project_id, _ = project_with_token_usage - response = api_client.get("/api/agents/backend-001/metrics") - - assert response.status_code == 200 - data = response.json() - - # Check structure - assert "agent_id" in data - assert "total_cost_usd" in data - assert "total_tokens" in data - assert "total_calls" in data - assert "by_call_type" in data - assert "by_project" in data - - # Check values - assert data["agent_id"] == "backend-001" - assert data["total_calls"] == 2 - assert data["total_tokens"] == 2250 - assert abs(data["total_cost_usd"] - 0.01575) < 0.00001 - - # Check by_call_type breakdown - assert len(data["by_call_type"]) == 2 # TASK_EXECUTION and CODE_REVIEW - - # Find task execution stats - task_exec_stats = next( - (ct for ct in data["by_call_type"] if ct["call_type"] == CallType.TASK_EXECUTION.value), - None, - ) - assert task_exec_stats is not None - assert task_exec_stats["calls"] == 1 - - # Find code review stats - code_review_stats = next( - (ct for ct in data["by_call_type"] if ct["call_type"] == CallType.CODE_REVIEW.value), - None, - ) - assert code_review_stats is not None - assert code_review_stats["calls"] == 1 - - # Check by_project breakdown - assert len(data["by_project"]) == 1 - assert data["by_project"][0]["project_id"] == project_id - - def test_project_filtering(self, api_client, project_with_token_usage): - """Test that project_id filtering works correctly.""" - project_id, _ = project_with_token_usage - - # Get metrics for backend-001 filtered by project_id - response = api_client.get(f"/api/agents/backend-001/metrics?project_id={project_id}") - - assert response.status_code == 200 - data = response.json() - - # Should only get records for this project - assert data["agent_id"] == "backend-001" - assert data["total_calls"] == 2 - assert len(data["by_project"]) == 1 - assert data["by_project"][0]["project_id"] == project_id - - def test_nonexistent_agent_returns_empty(self, api_client): - """Test that nonexistent agent returns empty metrics.""" - response = api_client.get("/api/agents/nonexistent-agent/metrics") - - assert response.status_code == 200 - data = response.json() - - # Should return empty metrics - assert data["agent_id"] == "nonexistent-agent" - assert data["total_cost_usd"] == 0.0 - assert data["total_tokens"] == 0 - assert data["total_calls"] == 0 - assert len(data["by_call_type"]) == 0 - assert len(data["by_project"]) == 0 - - def test_agent_with_no_data_for_project_returns_empty( - self, api_client, project_with_token_usage - ): - """Test that agent with no data for project returns empty metrics.""" - project_id, _ = project_with_token_usage - - # Get metrics for frontend-001 filtered by the existing project (frontend-001 has no data for this project) - response = api_client.get(f"/api/agents/frontend-001/metrics?project_id={project_id}") - - assert response.status_code == 200 - data = response.json() - - # Should return empty metrics (frontend-001 has no token usage for this project) - assert data["agent_id"] == "frontend-001" - assert data["total_cost_usd"] == 0.0 - assert data["total_tokens"] == 0 - assert data["total_calls"] == 0 - assert len(data["by_call_type"]) == 0 - assert len(data["by_project"]) == 0 - - -class TestMetricsEndpointIntegration: - """Integration tests for metrics endpoints.""" - - def test_all_endpoints_consistent(self, api_client, project_with_token_usage): - """Test that all metrics endpoints return consistent data.""" - project_id, _ = project_with_token_usage - - # Get project costs - project_response = api_client.get(f"/api/projects/{project_id}/metrics/costs") - project_data = project_response.json() - - # Get agent costs for backend-001 - agent_response = api_client.get(f"/api/agents/backend-001/metrics?project_id={project_id}") - agent_data = agent_response.json() - - # Verify consistency - # backend-001 should have 2 calls in project - assert agent_data["total_calls"] == 2 - - # backend-001's cost should be in project's by_agent breakdown - backend_stats = next( - (a for a in project_data["by_agent"] if a["agent_id"] == "backend-001"), None - ) - assert backend_stats is not None - assert backend_stats["cost_usd"] == agent_data["total_cost_usd"] - assert backend_stats["total_tokens"] == agent_data["total_tokens"] - assert backend_stats["call_count"] == agent_data["total_calls"] - - -class TestProjectTokenTimeSeriesEndpoint: - """Test GET /api/projects/{id}/metrics/tokens/timeseries endpoint.""" - - def test_endpoint_exists(self, api_client, project_with_token_usage): - """Test that endpoint exists and returns 200.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = (now - timedelta(days=7)).strftime("%Y-%m-%d") - end_date = now.strftime("%Y-%m-%d") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date={start_date}&end_date={end_date}" - ) - assert response.status_code == 200 - - def test_returns_timeseries_structure(self, api_client, project_with_token_usage): - """Test that endpoint returns proper time series structure.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = (now - timedelta(days=7)).strftime("%Y-%m-%d") - end_date = now.strftime("%Y-%m-%d") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date={start_date}&end_date={end_date}&interval=day" - ) - - assert response.status_code == 200 - data = response.json() - - # Response should be an array of time series data points - assert isinstance(data, list) - assert len(data) > 0 - - # Each data point should have the expected structure - for point in data: - assert "timestamp" in point - assert "input_tokens" in point - assert "output_tokens" in point - assert "total_tokens" in point - assert "cost_usd" in point - - def test_aggregates_by_day(self, api_client, project_with_token_usage): - """Test that data is properly aggregated by day interval.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = (now - timedelta(days=3)).strftime("%Y-%m-%d") - end_date = now.strftime("%Y-%m-%d") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date={start_date}&end_date={end_date}&interval=day" - ) - - assert response.status_code == 200 - data = response.json() - - # Should have data points for days with usage - assert len(data) > 0 - - # Verify total tokens across all data points matches expected - total_input = sum(point["input_tokens"] for point in data) - total_output = sum(point["output_tokens"] for point in data) - - # We have 3 records: today (1500 tokens), yesterday (750), two days ago (3000) - # Filtering to last 3 days should include all - assert total_input + total_output > 0 - - def test_supports_hour_interval(self, api_client, project_with_token_usage): - """Test that hour interval is supported.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = now.strftime("%Y-%m-%d") - end_date = now.strftime("%Y-%m-%d") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date={start_date}&end_date={end_date}&interval=hour" - ) - - assert response.status_code == 200 - - def test_supports_week_interval(self, api_client, project_with_token_usage): - """Test that week interval is supported.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = (now - timedelta(days=14)).strftime("%Y-%m-%d") - end_date = now.strftime("%Y-%m-%d") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date={start_date}&end_date={end_date}&interval=week" - ) - - assert response.status_code == 200 - - def test_invalid_interval_returns_400(self, api_client, project_with_token_usage): - """Test that invalid interval returns 400 Bad Request.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = (now - timedelta(days=7)).strftime("%Y-%m-%d") - end_date = now.strftime("%Y-%m-%d") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date={start_date}&end_date={end_date}&interval=invalid" - ) - - assert response.status_code == 400 - assert "interval" in response.json()["detail"].lower() - - def test_missing_dates_returns_400(self, api_client, project_with_token_usage): - """Test that missing required date parameters returns 400.""" - project_id, _ = project_with_token_usage - - # Missing start_date - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries?end_date=2025-01-01" - ) - assert response.status_code == 400 - - # Missing end_date - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries?start_date=2025-01-01" - ) - assert response.status_code == 400 - - def test_empty_date_range_returns_empty_array(self, api_client, project_with_token_usage): - """Test that date range with no data returns empty array.""" - project_id, _ = project_with_token_usage - - # Use date range far in the future with no data - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date=2099-01-01&end_date=2099-01-07" - ) - - assert response.status_code == 200 - data = response.json() - assert isinstance(data, list) - assert len(data) == 0 - - def test_nonexistent_project_returns_404(self, api_client): - """Test that nonexistent project returns 404.""" - response = api_client.get( - "/api/projects/99999/metrics/tokens/timeseries" - "?start_date=2025-01-01&end_date=2025-01-07" - ) - assert response.status_code == 404 - - def test_accepts_iso8601_dates(self, api_client, project_with_token_usage): - """Test that full ISO 8601 dates with time are accepted.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = (now - timedelta(days=7)).isoformat().replace("+00:00", "Z") - end_date = now.isoformat().replace("+00:00", "Z") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date={start_date}&end_date={end_date}" - ) - - assert response.status_code == 200 - - def test_invalid_month_returns_400(self, api_client, project_with_token_usage): - """Test that invalid month (13) returns 400 Bad Request.""" - project_id, _ = project_with_token_usage - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date=2025-13-01&end_date=2025-01-07" - ) - - assert response.status_code == 400 - assert "Invalid date format" in response.json()["detail"] - - def test_invalid_day_returns_400(self, api_client, project_with_token_usage): - """Test that invalid day (32) returns 400 Bad Request.""" - project_id, _ = project_with_token_usage - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date=2025-01-32&end_date=2025-01-07" - ) - - assert response.status_code == 400 - assert "Invalid date format" in response.json()["detail"] - - def test_malformed_date_returns_400(self, api_client, project_with_token_usage): - """Test that malformed date string returns 400 Bad Request.""" - project_id, _ = project_with_token_usage - - response = api_client.get( - f"/api/projects/{project_id}/metrics/tokens/timeseries" - f"?start_date=not-a-date&end_date=2025-01-07" - ) - - assert response.status_code == 400 - assert "Invalid date format" in response.json()["detail"] - - -class TestProjectCostMetricsDateFiltering: - """Test date filtering on GET /api/projects/{id}/metrics/costs endpoint.""" - - def test_date_filtering_reduces_costs(self, api_client, project_with_token_usage): - """Test that date filtering reduces returned costs.""" - project_id, _ = project_with_token_usage - - # Get all-time costs - all_time_response = api_client.get(f"/api/projects/{project_id}/metrics/costs") - all_time_data = all_time_response.json() - - # Get today-only costs (should be less than all-time) - now = datetime.now(timezone.utc) - start_date = now.replace(hour=0, minute=0, second=0, microsecond=0).strftime("%Y-%m-%d") - end_date = now.strftime("%Y-%m-%d") - - filtered_response = api_client.get( - f"/api/projects/{project_id}/metrics/costs" - f"?start_date={start_date}&end_date={end_date}" - ) - - assert filtered_response.status_code == 200 - filtered_data = filtered_response.json() - - # Filtered should have fewer or equal costs (today only vs all time) - assert filtered_data["total_cost_usd"] <= all_time_data["total_cost_usd"] - assert filtered_data["total_calls"] <= all_time_data["total_calls"] - - def test_date_filtering_with_iso8601_format(self, api_client, project_with_token_usage): - """Test that ISO 8601 format works for costs date filtering.""" - project_id, _ = project_with_token_usage - now = datetime.now(timezone.utc) - start_date = (now - timedelta(days=7)).isoformat().replace("+00:00", "Z") - end_date = now.isoformat().replace("+00:00", "Z") - - response = api_client.get( - f"/api/projects/{project_id}/metrics/costs" - f"?start_date={start_date}&end_date={end_date}" - ) - - assert response.status_code == 200 - - def test_date_filtering_backwards_compatible(self, api_client, project_with_token_usage): - """Test that costs endpoint still works without date params (backward compatible).""" - project_id, _ = project_with_token_usage - - # Should work without any date params - response = api_client.get(f"/api/projects/{project_id}/metrics/costs") - assert response.status_code == 200 - - data = response.json() - assert "total_cost_usd" in data - assert "by_agent" in data - assert "by_model" in data diff --git a/tests/api/test_api_prd.py b/tests/api/test_api_prd.py deleted file mode 100644 index 18874876..00000000 --- a/tests/api/test_api_prd.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Tests for PRD API endpoint (cf-26). - -Sprint 2 Foundation Contract: -GET /api/projects/{id}/prd → PRDResponse - -Tests follow RED-GREEN-REFACTOR TDD cycle. -""" - -import pytest -from datetime import datetime, UTC - - -def get_app(): - """Get the current app instance after module reload.""" - from codeframe.ui.server import app - - return app - - -@pytest.fixture(scope="function") -def project_with_prd(api_client): - """Create test project with PRD content. - - Args: - api_client: FastAPI test client - - Returns: - Tuple of (project_id, prd_content, generated_at) - """ - # Create project - project_id = get_app().state.db.create_project( - name="Test PRD Project", description="Test PRD Project project" - ) - - # Store PRD content in database - prd_content = """# Product Requirements Document - -## Executive Summary -Build a task management system. - -## Problem Statement -Users need better task tracking. - -## Features & Requirements -- Create tasks -- Assign tasks -- Track progress -""" - - # Store PRD in memory table (key="content" matches repository query) - get_app().state.db.create_memory( - project_id=project_id, category="prd", key="content", value=prd_content - ) - - # Store metadata - generated_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") - get_app().state.db.create_memory( - project_id=project_id, category="prd", key="generated_at", value=generated_at - ) - - return project_id, prd_content, generated_at - - -class TestPRDEndpointBasics: - """Test basic PRD endpoint functionality.""" - - def test_prd_endpoint_exists(self, api_client, project_with_prd): - """Test that GET /api/projects/{id}/prd endpoint exists.""" - project_id, _, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - - # Should not return 404 - assert response.status_code != 404 - - def test_prd_endpoint_returns_json(self, api_client, project_with_prd): - """Test that PRD endpoint returns JSON response.""" - project_id, _, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - - assert response.headers["content-type"] == "application/json" - - def test_prd_endpoint_returns_200_when_available(self, api_client, project_with_prd): - """Test that PRD endpoint returns 200 when PRD is available.""" - project_id, _, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - - assert response.status_code == 200 - - -class TestPRDResponseStructure: - """Test PRD response structure matches API contract.""" - - def test_prd_response_has_required_fields(self, api_client, project_with_prd): - """Test that PRD response includes all required fields. - - Required fields (API Contract): - - project_id: string - - prd_content: string - - generated_at: ISODate (RFC 3339) - - updated_at: ISODate (RFC 3339) - - status: 'available' | 'generating' | 'not_found' - """ - project_id, _, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - data = response.json() - - # Verify all required fields present - assert "project_id" in data - assert "prd_content" in data - assert "generated_at" in data - assert "updated_at" in data - assert "status" in data - - def test_prd_response_project_id_is_string(self, api_client, project_with_prd): - """Test that project_id is returned as string (not int).""" - project_id, _, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - data = response.json() - - # project_id should be string - assert isinstance(data["project_id"], str) - assert data["project_id"] == str(project_id) - - def test_prd_response_timestamps_are_rfc3339(self, api_client, project_with_prd): - """Test that timestamps follow RFC 3339 format with timezone.""" - project_id, _, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - data = response.json() - - # Verify generated_at is valid RFC 3339 - generated_at = data["generated_at"] - assert isinstance(generated_at, str) - # Should be parseable as ISO format with timezone - dt = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) - assert dt.tzinfo is not None # Must have timezone - - # Verify updated_at is valid RFC 3339 - updated_at = data["updated_at"] - assert isinstance(updated_at, str) - dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) - assert dt.tzinfo is not None - - def test_prd_response_status_is_available(self, api_client, project_with_prd): - """Test that status is 'available' when PRD exists.""" - project_id, _, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - data = response.json() - - assert data["status"] == "available" - - def test_prd_response_contains_correct_content(self, api_client, project_with_prd): - """Test that prd_content matches stored content.""" - project_id, prd_content, _ = project_with_prd - response = api_client.get(f"/api/projects/{project_id}/prd") - data = response.json() - - assert data["prd_content"] == prd_content - - -class TestPRDEndpointNotFound: - """Test PRD endpoint when PRD doesn't exist.""" - - def test_prd_not_found_returns_status_not_found(self, api_client): - """Test that status is 'not_found' when PRD doesn't exist.""" - # Create project without PRD - project_id = get_app().state.db.create_project( - name="No PRD Project", description="No PRD Project project" - ) - - response = api_client.get(f"/api/projects/{project_id}/prd") - data = response.json() - - assert response.status_code == 200 - assert data["status"] == "not_found" - - def test_prd_not_found_returns_empty_content(self, api_client): - """Test that prd_content is empty when PRD doesn't exist.""" - # Create project without PRD - project_id = get_app().state.db.create_project( - name="No PRD Project", description="No PRD Project project" - ) - - response = api_client.get(f"/api/projects/{project_id}/prd") - data = response.json() - - assert data["prd_content"] == "" - - def test_nonexistent_project_returns_404(self, api_client): - """Test that nonexistent project returns 404.""" - response = api_client.get("/api/projects/99999/prd") - - assert response.status_code == 404 - - -class TestPRDEndpointEdgeCases: - """Test PRD endpoint edge cases and error handling.""" - - def test_prd_endpoint_handles_invalid_project_id(self, api_client): - """Test that endpoint handles invalid project ID gracefully.""" - response = api_client.get("/api/projects/invalid/prd") - - # Should return 422 (validation error) or 404 - assert response.status_code in [422, 404] - - def test_prd_endpoint_with_very_large_content(self, api_client): - """Test that endpoint handles large PRD content.""" - # Create project with large PRD - project_id = get_app().state.db.create_project( - name="Large PRD Project", description="Large PRD Project project" - ) - - # Create large PRD content (>100KB) - key="content" matches repository query - large_content = "# PRD\n\n" + ("Lorem ipsum dolor sit amet. " * 10000) - get_app().state.db.create_memory( - project_id=project_id, category="prd", key="content", value=large_content - ) - - generated_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") - get_app().state.db.create_memory( - project_id=project_id, category="prd", key="generated_at", value=generated_at - ) - - response = api_client.get(f"/api/projects/{project_id}/prd") - - assert response.status_code == 200 - data = response.json() - assert len(data["prd_content"]) > 100000 - - def test_prd_updated_at_reflects_latest_change(self, api_client, project_with_prd): - """Test that updated_at reflects the most recent update.""" - project_id, _, _ = project_with_prd - - # Get initial response - response1 = api_client.get(f"/api/projects/{project_id}/prd") - data1 = response1.json() - - # Since we're just reading, updated_at should equal generated_at - # In a real scenario, updated_at would change on edits - assert data1["updated_at"] == data1["generated_at"] diff --git a/tests/api/test_api_session.py b/tests/api/test_api_session.py deleted file mode 100644 index c489fef2..00000000 --- a/tests/api/test_api_session.py +++ /dev/null @@ -1,399 +0,0 @@ -""" -Tests for Session API Endpoint (014-session-lifecycle, T031) - -Test Coverage: -1. GET /api/projects/{id}/session - Retrieve session state -2. Error handling: - - 404: Project not found - - Empty state when no session file exists -3. Session state structure validation -4. SessionManager integration - -Test Approach: TDD (RED-GREEN-REFACTOR) -""" - -import json -import pytest -from pathlib import Path -from datetime import datetime - - -def get_app(): - """Get the current app instance after module reload.""" - from codeframe.ui.server import app - - return app - - -def get_project_dir(project_id: int) -> Path: - """Get the workspace directory for a project.""" - project = get_app().state.db.get_project(project_id) - return Path(project["workspace_path"]) - - -@pytest.fixture -def test_project(api_client): - """Create a test project.""" - import os - - workspace_root = Path(os.environ.get("WORKSPACE_ROOT", "/tmp/workspaces")) - project_dir = workspace_root / "project-test" - - project_id = get_app().state.db.create_project( - name="Test Session Project", - description="Test project for session testing", - workspace_path=str(project_dir), - ) - return project_id - - -@pytest.fixture -def project_with_session(test_project, api_client): - """Create a test project with session state file.""" - # Get the workspace path from the project - project_dir = get_project_dir(test_project) - project_dir.mkdir(parents=True, exist_ok=True) - - codeframe_dir = project_dir / ".codeframe" - codeframe_dir.mkdir(exist_ok=True) - - session_file = codeframe_dir / "session_state.json" - session_data = { - "last_session": { - "summary": "Completed 3 tasks", - "timestamp": datetime.now().isoformat(), - }, - "next_actions": ["Complete Task #4", "Review PR #12", "Fix bug"], - "progress_pct": 68.5, - "active_blockers": [ - {"id": 1, "question": "Which OAuth?", "priority": "high"}, - {"id": 2, "question": "Schema?", "priority": "medium"}, - ], - } - session_file.write_text(json.dumps(session_data, indent=2)) - - return test_project, session_data - - -class TestSessionEndpoint: - """Test GET /api/projects/{id}/session endpoint (014-session-lifecycle)""" - - def test_get_session_success_with_existing_session(self, api_client, project_with_session): - """ - Test: Retrieve session state when session file exists - - Expected behavior: - - Return 200 OK - - Return complete session state - - Include all required fields - """ - project_id, expected_data = project_with_session - - response = api_client.get(f"/api/projects/{project_id}/session") - - assert response.status_code == 200 - data = response.json() - - # Verify structure - assert "last_session" in data - assert "next_actions" in data - assert "progress_pct" in data - assert "active_blockers" in data - - # Verify content - assert data["last_session"]["summary"] == expected_data["last_session"]["summary"] - assert data["next_actions"] == expected_data["next_actions"] - assert data["progress_pct"] == expected_data["progress_pct"] - assert len(data["active_blockers"]) == 2 - - def test_get_session_returns_empty_state_when_no_file(self, api_client, test_project): - """ - Test: Retrieve session when no session file exists - - Expected behavior: - - Return 200 OK (not 404) - - Return default empty state - - Include "No previous session" message - """ - response = api_client.get(f"/api/projects/{test_project}/session") - - assert response.status_code == 200 - data = response.json() - - # Verify empty state structure - assert data["last_session"]["summary"] == "No previous session" - assert data["next_actions"] == [] - assert data["progress_pct"] == 0.0 - assert data["active_blockers"] == [] - - def test_get_session_nonexistent_project(self, api_client): - """ - Test: Retrieve session for non-existent project - - Expected behavior: - - Return 404 NOT FOUND - - Include error message - """ - nonexistent_id = 99999 - - response = api_client.get(f"/api/projects/{nonexistent_id}/session") - - # Note: Current implementation may return 200 with empty state - # This test documents expected behavior - # API should ideally return 404 for non-existent projects - assert response.status_code in [200, 404] - - def test_session_state_structure_validation(self, api_client, project_with_session): - """ - Test: Validate session state structure conforms to interface - - Expected behavior: - - last_session has summary and timestamp - - next_actions is an array - - progress_pct is a number - - active_blockers is an array - """ - project_id, _ = project_with_session - - response = api_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - # Validate last_session structure - assert isinstance(data["last_session"], dict) - assert "summary" in data["last_session"] - assert "timestamp" in data["last_session"] - assert isinstance(data["last_session"]["summary"], str) - assert isinstance(data["last_session"]["timestamp"], str) - - # Validate next_actions - assert isinstance(data["next_actions"], list) - for action in data["next_actions"]: - assert isinstance(action, str) - - # Validate progress_pct - assert isinstance(data["progress_pct"], (int, float)) - assert 0 <= data["progress_pct"] <= 100 - - # Validate active_blockers - assert isinstance(data["active_blockers"], list) - for blocker in data["active_blockers"]: - assert isinstance(blocker, dict) - assert "id" in blocker - assert "question" in blocker - assert "priority" in blocker - - def test_session_handles_corrupted_json(self, api_client, test_project): - """ - Test: Handle corrupted session file gracefully - - Expected behavior: - - Return 200 OK - - Return empty state (fallback) - - Log error but don't crash - """ - project_dir = get_project_dir(test_project) - project_dir.mkdir(parents=True, exist_ok=True) - - codeframe_dir = project_dir / ".codeframe" - codeframe_dir.mkdir(exist_ok=True) - - session_file = codeframe_dir / "session_state.json" - session_file.write_text("{invalid json content") - - response = api_client.get(f"/api/projects/{test_project}/session") - - # Should handle gracefully and return empty state - assert response.status_code == 200 - data = response.json() - assert data["last_session"]["summary"] == "No previous session" - - def test_session_timestamp_format(self, api_client, project_with_session): - """ - Test: Verify timestamp is ISO 8601 format - - Expected behavior: - - Timestamp should be valid ISO 8601 string - - Can be parsed by datetime.fromisoformat() - """ - project_id, _ = project_with_session - - response = api_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - timestamp = data["last_session"]["timestamp"] - - # Should be parseable as ISO 8601 - try: - datetime.fromisoformat(timestamp.replace("Z", "+00:00")) - is_valid_iso = True - except ValueError: - is_valid_iso = False - - assert is_valid_iso, f"Timestamp {timestamp} is not valid ISO 8601" - - def test_session_next_actions_order_preserved(self, api_client, project_with_session): - """ - Test: Verify next_actions order is preserved - - Expected behavior: - - Actions should be returned in same order as stored - """ - project_id, expected_data = project_with_session - - response = api_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - assert data["next_actions"] == expected_data["next_actions"] - assert data["next_actions"][0] == "Complete Task #4" - assert data["next_actions"][1] == "Review PR #12" - - def test_session_blocker_structure(self, api_client, project_with_session): - """ - Test: Verify blocker objects have required fields - - Expected behavior: - - Each blocker has id, question, priority - - Priority is valid value - """ - project_id, _ = project_with_session - - response = api_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - for blocker in data["active_blockers"]: - assert "id" in blocker - assert "question" in blocker - assert "priority" in blocker - - assert isinstance(blocker["id"], int) - assert isinstance(blocker["question"], str) - assert blocker["priority"] in ["high", "medium", "low"] - - def test_session_progress_percentage_range(self, api_client, project_with_session): - """ - Test: Verify progress percentage is in valid range - - Expected behavior: - - Progress should be between 0 and 100 - """ - project_id, _ = project_with_session - - response = api_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - progress = data["progress_pct"] - assert 0 <= progress <= 100 - - def test_session_endpoint_response_time(self, api_client, project_with_session): - """ - Test: Verify endpoint responds quickly - - Expected behavior: - - Response time should be reasonable (< 1 second) - """ - import time - - project_id, _ = project_with_session - - start_time = time.time() - response = api_client.get(f"/api/projects/{project_id}/session") - elapsed = time.time() - start_time - - assert response.status_code == 200 - assert elapsed < 1.0, f"Response took {elapsed:.2f}s, expected < 1s" - - -class TestSessionEndpointEdgeCases: - """Test edge cases for session endpoint""" - - def test_session_with_empty_next_actions(self, api_client, test_project): - """ - Test: Session with empty next_actions array - - Expected behavior: - - Return empty array, not null - """ - project_dir = get_project_dir(test_project) - project_dir.mkdir(parents=True, exist_ok=True) - - codeframe_dir = project_dir / ".codeframe" - codeframe_dir.mkdir(exist_ok=True) - - session_file = codeframe_dir / "session_state.json" - session_data = { - "last_session": { - "summary": "Test", - "timestamp": datetime.now().isoformat(), - }, - "next_actions": [], - "progress_pct": 50.0, - "active_blockers": [], - } - session_file.write_text(json.dumps(session_data)) - - response = api_client.get(f"/api/projects/{test_project}/session") - data = response.json() - - assert data["next_actions"] == [] - assert isinstance(data["next_actions"], list) - - def test_session_with_zero_progress(self, api_client, test_project): - """ - Test: Session with 0% progress - - Expected behavior: - - Return 0, not null or negative - """ - project_dir = get_project_dir(test_project) - project_dir.mkdir(parents=True, exist_ok=True) - - codeframe_dir = project_dir / ".codeframe" - codeframe_dir.mkdir(exist_ok=True) - - session_file = codeframe_dir / "session_state.json" - session_data = { - "last_session": { - "summary": "Just started", - "timestamp": datetime.now().isoformat(), - }, - "next_actions": ["Start work"], - "progress_pct": 0.0, - "active_blockers": [], - } - session_file.write_text(json.dumps(session_data)) - - response = api_client.get(f"/api/projects/{test_project}/session") - data = response.json() - - assert data["progress_pct"] == 0.0 - assert isinstance(data["progress_pct"], (int, float)) - - def test_session_with_100_percent_progress(self, api_client, test_project): - """ - Test: Session with 100% progress - - Expected behavior: - - Return 100, indicating completion - """ - project_dir = get_project_dir(test_project) - project_dir.mkdir(parents=True, exist_ok=True) - - codeframe_dir = project_dir / ".codeframe" - codeframe_dir.mkdir(exist_ok=True) - - session_file = codeframe_dir / "session_state.json" - session_data = { - "last_session": { - "summary": "All tasks complete", - "timestamp": datetime.now().isoformat(), - }, - "next_actions": [], - "progress_pct": 100.0, - "active_blockers": [], - } - session_file.write_text(json.dumps(session_data)) - - response = api_client.get(f"/api/projects/{test_project}/session") - data = response.json() - - assert data["progress_pct"] == 100.0 diff --git a/tests/api/test_blocker_resolution_api.py b/tests/api/test_blocker_resolution_api.py deleted file mode 100644 index 66ae072f..00000000 --- a/tests/api/test_blocker_resolution_api.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Tests for Blocker Resolution API endpoint (049-human-in-loop). - -Phase 4 / User Story 2: Blocker Resolution via Dashboard -POST /api/blockers/{blocker_id}/resolve → BlockerResolveResponse - -Tests follow RED-GREEN-REFACTOR TDD cycle. -""" - -import pytest -from datetime import datetime - -from codeframe.core.models import BlockerType, BlockerStatus - - -def get_app(): - """Get the current app instance after module reload. - - Imports app locally to ensure we get the freshly reloaded instance - after api_client fixture reloads codeframe.ui.server. - """ - from codeframe.ui.server import app - - return app - - -@pytest.fixture(scope="function") -def project_with_blocker(api_client): - """Create test project with a pending blocker. - - Args: - api_client: FastAPI test client - - Returns: - Tuple of (project_id, blocker_id, agent_id, question) - """ - # Create project - project_id = get_app().state.db.create_project( - name="Test Blocker Project", description="Test project for blocker resolution API tests" - ) - - # Create a blocker - agent_id = "backend-worker-001" - question = "Should I use JWT or session-based authentication?" - blocker_id = get_app().state.db.create_blocker( - agent_id=agent_id, - project_id=project_id, - task_id=None, - blocker_type=BlockerType.SYNC, - question=question, - ) - - return project_id, blocker_id, agent_id, question - - -class TestBlockerResolveEndpointBasics: - """Test basic blocker resolution endpoint functionality.""" - - def test_resolve_endpoint_exists(self, api_client, project_with_blocker): - """Test that POST /api/blockers/{id}/resolve endpoint exists.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", - json={"answer": "Use JWT for stateless API authentication"}, - ) - - # Should not return 404 - assert response.status_code != 404 - - def test_resolve_endpoint_returns_json(self, api_client, project_with_blocker): - """Test that resolve endpoint returns JSON response.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", - json={"answer": "Use JWT for stateless API authentication"}, - ) - - assert response.headers["content-type"] == "application/json" - - def test_resolve_endpoint_returns_200_on_success(self, api_client, project_with_blocker): - """Test that resolve endpoint returns 200 on successful resolution.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", - json={"answer": "Use JWT for stateless API authentication"}, - ) - - assert response.status_code == 200 - - -class TestBlockerResolveResponseStructure: - """Test blocker resolution response structure matches API contract.""" - - def test_resolve_response_has_required_fields(self, api_client, project_with_blocker): - """Test that resolve response includes all required fields. - - Required fields (API Contract): - - blocker_id: int - - status: 'RESOLVED' - - resolved_at: ISODate (RFC 3339) - """ - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Use JWT"} - ) - data = response.json() - - # Verify all required fields present - assert "blocker_id" in data - assert "status" in data - assert "resolved_at" in data - - def test_resolve_response_blocker_id_is_int(self, api_client, project_with_blocker): - """Test that blocker_id is returned as int.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Use JWT"} - ) - data = response.json() - - assert isinstance(data["blocker_id"], int) - assert data["blocker_id"] == blocker_id - - def test_resolve_response_status_is_resolved(self, api_client, project_with_blocker): - """Test that status is 'RESOLVED' after successful resolution.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Use JWT"} - ) - data = response.json() - - assert data["status"] == "RESOLVED" - - def test_resolve_response_timestamp_is_rfc3339(self, api_client, project_with_blocker): - """Test that resolved_at follows RFC 3339 format with timezone.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Use JWT"} - ) - data = response.json() - - # Verify resolved_at is valid RFC 3339 - resolved_at = data["resolved_at"] - assert isinstance(resolved_at, str) - # Should be parseable as ISO format with timezone - dt = datetime.fromisoformat(resolved_at.replace("Z", "+00:00")) - assert dt.tzinfo is not None # Must have timezone - - -class TestBlockerResolutionPersistence: - """Test that blocker resolution is persisted to database.""" - - def test_blocker_status_updated_in_database(self, api_client, project_with_blocker): - """Test that blocker status is updated to RESOLVED in database.""" - _, blocker_id, _, _ = project_with_blocker - - # Verify initial status is PENDING - blocker_before = get_app().state.db.get_blocker(blocker_id) - assert blocker_before["status"] == BlockerStatus.PENDING.value - - # Resolve blocker - api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": "Use JWT"}) - - # Verify status updated to RESOLVED - blocker_after = get_app().state.db.get_blocker(blocker_id) - assert blocker_after["status"] == BlockerStatus.RESOLVED.value - - def test_answer_stored_in_database(self, api_client, project_with_blocker): - """Test that user's answer is stored in database.""" - _, blocker_id, _, _ = project_with_blocker - - answer = "Use JWT for stateless API authentication" - api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": answer}) - - # Verify answer stored - blocker = get_app().state.db.get_blocker(blocker_id) - assert blocker["answer"] == answer - - def test_resolved_at_timestamp_stored(self, api_client, project_with_blocker): - """Test that resolved_at timestamp is stored in database.""" - _, blocker_id, _, _ = project_with_blocker - - # Verify no resolved_at before resolution - blocker_before = get_app().state.db.get_blocker(blocker_id) - assert blocker_before["resolved_at"] is None - - # Resolve blocker - api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": "Use JWT"}) - - # Verify resolved_at is now set - blocker_after = get_app().state.db.get_blocker(blocker_id) - assert blocker_after["resolved_at"] is not None - - -class TestBlockerResolutionValidation: - """Test input validation for blocker resolution.""" - - def test_resolve_requires_answer_field(self, api_client, project_with_blocker): - """Test that answer field is required.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post(f"/api/blockers/{blocker_id}/resolve", json={}) - - # Should return 422 (validation error) - assert response.status_code == 422 - - def test_resolve_rejects_empty_answer(self, api_client, project_with_blocker): - """Test that empty answer is rejected.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": ""}) - - # Should return 422 (validation error) - assert response.status_code == 422 - - def test_resolve_rejects_whitespace_only_answer(self, api_client, project_with_blocker): - """Test that whitespace-only answer is rejected.""" - _, blocker_id, _, _ = project_with_blocker - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": " \n\t "} - ) - - # Should return 422 (validation error) - assert response.status_code == 422 - - def test_resolve_rejects_answer_exceeding_max_length(self, api_client, project_with_blocker): - """Test that answer exceeding 5000 characters is rejected.""" - _, blocker_id, _, _ = project_with_blocker - - # Create answer with 5001 characters - long_answer = "A" * 5001 - - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": long_answer} - ) - - # Should return 422 (validation error) - assert response.status_code == 422 - - def test_resolve_accepts_answer_at_max_length(self, api_client, project_with_blocker): - """Test that answer with exactly 5000 characters is accepted.""" - _, blocker_id, _, _ = project_with_blocker - - # Create answer with exactly 5000 characters - max_answer = "A" * 5000 - - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": max_answer} - ) - - # Should succeed - assert response.status_code == 200 - - -class TestBlockerResolutionConflicts: - """Test duplicate resolution prevention (409 Conflict).""" - - def test_duplicate_resolution_returns_409(self, api_client, project_with_blocker): - """Test that resolving already-resolved blocker returns 409 Conflict.""" - _, blocker_id, _, _ = project_with_blocker - - # First resolution - should succeed - response1 = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 1"} - ) - assert response1.status_code == 200 - - # Second resolution - should fail with 409 - response2 = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 2"} - ) - assert response2.status_code == 409 - - def test_duplicate_resolution_preserves_first_answer(self, api_client, project_with_blocker): - """Test that duplicate resolution doesn't overwrite first answer.""" - _, blocker_id, _, _ = project_with_blocker - - # First resolution - api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 1"}) - - # Second resolution (should fail) - api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 2"}) - - # Verify first answer preserved - blocker = get_app().state.db.get_blocker(blocker_id) - assert blocker["answer"] == "Answer 1" - - def test_conflict_response_includes_blocker_id(self, api_client, project_with_blocker): - """Test that 409 conflict response includes blocker_id.""" - _, blocker_id, _, _ = project_with_blocker - - # First resolution - api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 1"}) - - # Second resolution - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 2"} - ) - data = response.json() - - assert "blocker_id" in data - assert data["blocker_id"] == blocker_id - - def test_conflict_response_includes_error_message(self, api_client, project_with_blocker): - """Test that 409 conflict response includes helpful error message.""" - _, blocker_id, _, _ = project_with_blocker - - # First resolution - api_client.post(f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 1"}) - - # Second resolution - response = api_client.post( - f"/api/blockers/{blocker_id}/resolve", json={"answer": "Answer 2"} - ) - data = response.json() - - assert "error" in data - assert "already resolved" in data["error"].lower() - - -class TestBlockerResolutionNotFound: - """Test blocker resolution for non-existent blockers.""" - - def test_nonexistent_blocker_returns_404(self, api_client): - """Test that resolving non-existent blocker returns 404.""" - response = api_client.post("/api/blockers/99999/resolve", json={"answer": "Some answer"}) - - assert response.status_code == 404 - - def test_404_response_includes_blocker_id(self, api_client): - """Test that 404 response includes blocker_id.""" - response = api_client.post("/api/blockers/99999/resolve", json={"answer": "Some answer"}) - data = response.json() - - assert "blocker_id" in data or "detail" in data - - def test_invalid_blocker_id_returns_422(self, api_client): - """Test that invalid blocker ID format returns 422.""" - response = api_client.post("/api/blockers/invalid/resolve", json={"answer": "Some answer"}) - - # Should return 422 (validation error) - assert response.status_code == 422 diff --git a/tests/api/test_endpoints_database.py b/tests/api/test_endpoints_database.py deleted file mode 100644 index 9d579499..00000000 --- a/tests/api/test_endpoints_database.py +++ /dev/null @@ -1,534 +0,0 @@ -"""Tests for Status Server endpoints with database integration. - -Following TDD: These tests are written FIRST, before implementation. -Task: cf-8.3 - Wire endpoints to database -""" - -import pytest -from codeframe.core.models import AgentMaturity, Task, TaskStatus - - -def get_app(): - """Get the current app instance after module reload.""" - from codeframe.ui.server import app - - return app - - -@pytest.mark.unit -class TestProjectsEndpoint: - """Test GET /api/projects endpoint with database.""" - - def test_list_projects_empty_database(self, api_client): - """Test listing projects when database is empty.""" - # ACT - response = api_client.get("/api/projects") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert "projects" in data - assert data["projects"] == [] - - def test_list_projects_with_data(self, api_client): - """Test listing projects with actual database data.""" - # Create test projects in database - db = get_app().state.db - db.create_project("test-project-1", "Test Project 1 project") - db.create_project("test-project-2", "Test Project 2 project") - - # ACT - response = api_client.get("/api/projects") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert "projects" in data - assert len(data["projects"]) == 2 - - # Verify project data - projects = {p["name"]: p for p in data["projects"]} - assert "test-project-1" in projects - assert projects["test-project-1"]["status"] == "init" - assert "test-project-2" in projects - assert projects["test-project-2"]["status"] == "init" - - def test_list_projects_returns_all_fields(self, api_client): - """Test that list_projects returns all expected fields.""" - db = get_app().state.db - db.create_project("full-project", "Full Project project") - - # ACT - response = api_client.get("/api/projects") - - # ASSERT - assert response.status_code == 200 - data = response.json() - project = data["projects"][0] - - # Verify required fields exist - assert "id" in project - assert "name" in project - assert "status" in project - assert "created_at" in project - - -@pytest.mark.unit -class TestProjectStatusEndpoint: - """Test GET /api/projects/{id}/status endpoint with database.""" - - def test_get_project_status_success(self, api_client): - """Test getting project status for existing project.""" - db = get_app().state.db - project_id = db.create_project("status-project", "Status Project project") - - # ACT - response = api_client.get(f"/api/projects/{project_id}/status") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert data["project_id"] == project_id - assert data["name"] == "status-project" - assert data["status"] == "init" - - def test_get_project_status_not_found(self, api_client): - """Test getting status for non-existent project returns 404.""" - # ACT - response = api_client.get("/api/projects/99999/status") - - # ASSERT - assert response.status_code == 404 - data = response.json() - assert "detail" in data - assert "not found" in data["detail"].lower() - - def test_get_project_status_returns_complete_data(self, api_client): - """Test that project status returns all expected fields.""" - db = get_app().state.db - project_id = db.create_project("complete-project", "Complete Project project") - - # ACT - response = api_client.get(f"/api/projects/{project_id}/status") - - # ASSERT - assert response.status_code == 200 - data = response.json() - - # Verify all expected fields - assert "project_id" in data - assert "name" in data - assert "status" in data - assert isinstance(data["project_id"], int) - assert isinstance(data["name"], str) - assert isinstance(data["status"], str) - - -@pytest.mark.unit -class TestAgentsEndpoint: - """Test GET /api/projects/{id}/agents endpoint with database.""" - - def test_get_agents_empty_list(self, api_client): - """Test getting agents when no agents exist for project.""" - db = get_app().state.db - project_id = db.create_project("no-agents-project", "No Agents Project project") - - # ACT - response = api_client.get(f"/api/projects/{project_id}/agents") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert isinstance(data, list) - assert data == [] - - def test_get_agents_with_data(self, api_client): - """Test getting agents with actual database data.""" - db = get_app().state.db - project_id = db.create_project("agents-project", "Agents Project project") - - # Create test agents and assign them to project - db.create_agent("lead-agent", "lead", "claude", AgentMaturity.D3) - db.create_agent("backend-agent", "backend", "claude", AgentMaturity.D2) - - # Assign agents to project - db.assign_agent_to_project(project_id, "lead-agent", "leader") - db.assign_agent_to_project(project_id, "backend-agent", "worker") - - # ACT - response = api_client.get(f"/api/projects/{project_id}/agents") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert isinstance(data, list) - assert len(data) == 2 - - # Verify agent data - agents = {a["agent_id"]: a for a in data} - assert "lead-agent" in agents - assert "backend-agent" in agents - - def test_get_agents_returns_all_fields(self, api_client): - """Test that agents endpoint returns all expected fields.""" - db = get_app().state.db - project_id = db.create_project("full-agents-project", "Full Agents Project project") - db.create_agent("test-agent", "test", "claude", AgentMaturity.D4) - - # Assign agent to project - db.assign_agent_to_project(project_id, "test-agent", "worker") - - # ACT - response = api_client.get(f"/api/projects/{project_id}/agents") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert isinstance(data, list) - assert len(data) == 1 - agent = data[0] - - # Verify required fields (agent assignment response) - assert "agent_id" in agent - assert "assignment_id" in agent - assert "role" in agent - - -@pytest.mark.integration -class TestEndpointDatabaseIntegration: - """Integration tests for endpoints with database.""" - - def test_complete_project_workflow_via_api(self, api_client): - """Test complete workflow: create project, get status, verify agents.""" - db = get_app().state.db - - # ACT: Create project and agent - project_id = db.create_project("workflow-project", "Workflow Project project") - db.create_agent("workflow-lead", "lead", "claude", AgentMaturity.D3) - - # Test 1: List projects - verify our project exists (don't assume total count) - response = api_client.get("/api/projects") - assert response.status_code == 200 - projects = response.json()["projects"] - assert any(p["id"] == project_id or p["name"] == "workflow-project" for p in projects) - - # Test 2: Get project status - response = api_client.get(f"/api/projects/{project_id}/status") - assert response.status_code == 200 - status = response.json() - assert status["name"] == "workflow-project" - assert status["status"] == "init" - - # Test 3: Get agents - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() # Returns list directly, not wrapped in dict - assert len(agents) == 0 # No agents assigned yet - - def test_endpoints_survive_multiple_requests(self, api_client): - """Test that endpoints work consistently across multiple requests.""" - db = get_app().state.db - project_id = db.create_project("stable-project", "Stable Project project") - - # ACT & ASSERT: Make multiple requests - for _ in range(5): - # List projects - verify our project exists (don't assume total count) - response = api_client.get("/api/projects") - assert response.status_code == 200 - projects = response.json()["projects"] - assert any(p["id"] == project_id or p["name"] == "stable-project" for p in projects) - - # Get project status - response = api_client.get(f"/api/projects/{project_id}/status") - assert response.status_code == 200 - assert response.json()["name"] == "stable-project" - - # Get agents - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - - -@pytest.mark.unit -class TestProjectTasksEndpoint: - """Test GET /api/projects/{id}/tasks endpoint with database.""" - - def test_get_tasks_empty_database(self, api_client): - """Test getting tasks when project has no tasks.""" - db = get_app().state.db - project_id = db.create_project("no-tasks-project", "No Tasks Project project") - - # ACT - response = api_client.get(f"/api/projects/{project_id}/tasks") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert "tasks" in data - assert "total" in data - assert data["tasks"] == [] - assert data["total"] == 0 - - def test_get_tasks_with_data(self, api_client): - """Test getting tasks with actual database data.""" - db = get_app().state.db - project_id = db.create_project("tasks-project", "Tasks Project project") - - # Create test tasks - db.create_task( - Task( - project_id=project_id, - task_number="1", - title="Task 1", - description="First task", - status=TaskStatus.PENDING, - ) - ) - db.create_task( - Task( - project_id=project_id, - task_number="2", - title="Task 2", - description="Second task", - status=TaskStatus.IN_PROGRESS, - ) - ) - db.create_task( - Task( - project_id=project_id, - task_number="3", - title="Task 3", - description="Third task", - status=TaskStatus.COMPLETED, - ) - ) - - # ACT - response = api_client.get(f"/api/projects/{project_id}/tasks") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert "tasks" in data - assert "total" in data - assert len(data["tasks"]) == 3 - assert data["total"] == 3 - - def test_get_tasks_status_filtering(self, api_client): - """Test status filtering returns only matching tasks.""" - db = get_app().state.db - project_id = db.create_project("filter-project", "Filter Project project") - - # Create tasks with different statuses - db.create_task( - Task( - project_id=project_id, - task_number="1", - title="Task 1", - description="", - status=TaskStatus.PENDING, - ) - ) - db.create_task( - Task( - project_id=project_id, - task_number="2", - title="Task 2", - description="", - status=TaskStatus.IN_PROGRESS, - ) - ) - db.create_task( - Task( - project_id=project_id, - task_number="3", - title="Task 3", - description="", - status=TaskStatus.PENDING, - ) - ) - - # ACT - Filter for pending tasks - response = api_client.get(f"/api/projects/{project_id}/tasks?status=pending") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert len(data["tasks"]) == 2 - assert data["total"] == 2 - assert all(t["status"] == "pending" for t in data["tasks"]) - - def test_get_tasks_pagination(self, api_client): - """Test pagination with limit and offset.""" - db = get_app().state.db - project_id = db.create_project("pagination-project", "Pagination Project project") - - # Create 10 tasks - for i in range(1, 11): - db.create_task( - Task( - project_id=project_id, - task_number=str(i), - title=f"Task {i}", - description="", - status=TaskStatus.PENDING, - ) - ) - - # ACT - Get first 5 tasks - response = api_client.get(f"/api/projects/{project_id}/tasks?limit=5&offset=0") - assert response.status_code == 200 - data = response.json() - assert len(data["tasks"]) == 5 - assert data["total"] == 10 - - # ACT - Get next 5 tasks - response = api_client.get(f"/api/projects/{project_id}/tasks?limit=5&offset=5") - assert response.status_code == 200 - data = response.json() - assert len(data["tasks"]) == 5 - assert data["total"] == 10 - - def test_get_tasks_project_not_found(self, api_client): - """Test getting tasks for non-existent project returns 404.""" - # ACT - response = api_client.get("/api/projects/99999/tasks") - - # ASSERT - assert response.status_code == 404 - data = response.json() - assert "detail" in data - assert "not found" in data["detail"].lower() - - def test_get_tasks_total_count_accuracy(self, api_client): - """Test that total count reflects filtered results, not all tasks.""" - db = get_app().state.db - project_id = db.create_project("count-project", "Count Project project") - - # Create 5 pending and 3 completed tasks - for i in range(1, 6): - db.create_task( - Task( - project_id=project_id, - task_number=str(i), - title=f"Task {i}", - description="", - status=TaskStatus.PENDING, - ) - ) - for i in range(6, 9): - db.create_task( - Task( - project_id=project_id, - task_number=str(i), - title=f"Task {i}", - description="", - status=TaskStatus.COMPLETED, - ) - ) - - # ACT - Get all tasks - response = api_client.get(f"/api/projects/{project_id}/tasks") - assert response.status_code == 200 - assert response.json()["total"] == 8 - - # ACT - Get only pending tasks - response = api_client.get(f"/api/projects/{project_id}/tasks?status=pending") - assert response.status_code == 200 - data = response.json() - assert data["total"] == 5 - assert len(data["tasks"]) == 5 - - def test_get_tasks_edge_cases(self, api_client): - """Test edge cases: offset > total.""" - db = get_app().state.db - project_id = db.create_project("edge-project", "Edge Project project") - - # Create 3 tasks - for i in range(1, 4): - db.create_task( - Task( - project_id=project_id, - task_number=str(i), - title=f"Task {i}", - description="", - status=TaskStatus.PENDING, - ) - ) - - # ACT - Offset beyond total tasks (valid, just returns empty) - response = api_client.get(f"/api/projects/{project_id}/tasks?offset=10") - assert response.status_code == 200 - data = response.json() - assert data["tasks"] == [] - assert data["total"] == 3 - - -@pytest.mark.unit -class TestProjectTasksEndpointSecurity: - """Security tests for GET /api/projects/{id}/tasks endpoint.""" - - def test_get_tasks_negative_limit_rejected(self, api_client): - """Test that negative limit is rejected with 422.""" - db = get_app().state.db - project_id = db.create_project("security-project", "Security Project project") - - # ACT - Try negative limit - response = api_client.get(f"/api/projects/{project_id}/tasks?limit=-10") - - # ASSERT - assert response.status_code == 422 - data = response.json() - assert "detail" in data - - def test_get_tasks_zero_limit_rejected(self, api_client): - """Test that zero limit is rejected with 422.""" - db = get_app().state.db - project_id = db.create_project("security-project", "Security Project project") - - # ACT - Try zero limit - response = api_client.get(f"/api/projects/{project_id}/tasks?limit=0") - - # ASSERT - assert response.status_code == 422 - data = response.json() - assert "detail" in data - - def test_get_tasks_excessive_limit_rejected(self, api_client): - """Test that excessive limit (>1000) is rejected with 422.""" - db = get_app().state.db - project_id = db.create_project("security-project", "Security Project project") - - # ACT - Try excessive limit - response = api_client.get(f"/api/projects/{project_id}/tasks?limit=999999") - - # ASSERT - assert response.status_code == 422 - data = response.json() - assert "detail" in data - - def test_get_tasks_negative_offset_rejected(self, api_client): - """Test that negative offset is rejected with 422.""" - db = get_app().state.db - project_id = db.create_project("security-project", "Security Project project") - - # ACT - Try negative offset - response = api_client.get(f"/api/projects/{project_id}/tasks?offset=-5") - - # ASSERT - assert response.status_code == 422 - data = response.json() - assert "detail" in data - - def test_get_tasks_valid_max_limit_accepted(self, api_client): - """Test that limit=1000 (max valid) is accepted.""" - db = get_app().state.db - project_id = db.create_project("security-project", "Security Project project") - - # ACT - Try max valid limit - response = api_client.get(f"/api/projects/{project_id}/tasks?limit=1000") - - # ASSERT - assert response.status_code == 200 - data = response.json() - assert "tasks" in data - assert "total" in data diff --git a/tests/api/test_git_api.py b/tests/api/test_git_api.py deleted file mode 100644 index 83559d84..00000000 --- a/tests/api/test_git_api.py +++ /dev/null @@ -1,821 +0,0 @@ -"""Tests for Git REST API endpoints (#270). - -This module tests the git router endpoints for: -- Branch creation and management -- Commit creation and listing -- Git status retrieval - -Tests follow RED-GREEN-REFACTOR TDD cycle. -""" - -import tempfile -from pathlib import Path -from typing import Generator -import pytest -import git - - -def get_app(): - """Get the current app instance after module reload. - - Imports app locally to ensure we get the freshly reloaded instance - after api_client fixture reloads codeframe.ui.server. - """ - from codeframe.ui.server import app - - return app - - -@pytest.fixture(scope="function") -def test_project_with_git(api_client) -> Generator[dict, None, None]: - """Create a test project with an initialized git repository. - - Args: - api_client: FastAPI test client - - Yields: - Project dictionary with id, workspace_path, and git repo - """ - db = get_app().state.db - - # Create temporary directory for git workspace - with tempfile.TemporaryDirectory() as temp_dir: - workspace_path = Path(temp_dir) / "project" - workspace_path.mkdir(parents=True) - - # Initialize git repository - repo = git.Repo.init(workspace_path) - - # Create initial commit (required for branch operations) - readme_path = workspace_path / "README.md" - readme_path.write_text("# Test Project\n") - repo.index.add(["README.md"]) - repo.index.commit("Initial commit") - - # Create project in database with workspace path - project_id = db.create_project( - name="Test Git Project", - description="Test project for git API tests", - workspace_path=str(workspace_path), - ) - - yield { - "id": project_id, - "workspace_path": str(workspace_path), - "repo": repo, - } - - -@pytest.fixture(scope="function") -def test_project_with_issue(test_project_with_git, api_client) -> Generator[dict, None, None]: - """Create a test project with an issue for branch creation. - - Args: - test_project_with_git: Project fixture with git repository - api_client: FastAPI test client - - Yields: - Dictionary with project_id, issue_id, issue_number, workspace_path - """ - from codeframe.core.models import Issue - - db = get_app().state.db - project = test_project_with_git - - # Create an issue for the project using Issue model - issue = Issue( - project_id=project["id"], - issue_number="1.1", - title="User Authentication", - description="Implement user authentication feature", - priority=1, - ) - issue_id = db.create_issue(issue) - - yield { - "project_id": project["id"], - "issue_id": issue_id, - "issue_number": "1.1", - "issue_title": "User Authentication", - "workspace_path": project["workspace_path"], - "repo": project["repo"], - } - - -@pytest.fixture(scope="function") -def test_project_with_task(test_project_with_issue, api_client) -> Generator[dict, None, None]: - """Create a test project with a task for commit operations. - - Args: - test_project_with_issue: Project fixture with issue - api_client: FastAPI test client - - Yields: - Dictionary with project_id, issue_id, task_id, workspace_path - """ - from codeframe.core.models import Task, TaskStatus - - db = get_app().state.db - project = test_project_with_issue - - # Create a task for the issue using Task model - task = Task( - project_id=project["project_id"], - task_number="1.1.1", - title="Implement login endpoint", - description="Create POST /api/login endpoint", - status=TaskStatus.IN_PROGRESS, - ) - task_id = db.create_task(task) - - yield { - **project, - "task_id": task_id, - "task_number": "1.1.1", - } - - -# ============================================================================ -# Branch Creation Tests -# ============================================================================ - - -class TestGitBranchCreation: - """Test branch creation endpoint: POST /api/projects/{id}/git/branches.""" - - def test_create_branch_endpoint_exists(self, api_client, test_project_with_issue): - """Test that POST /api/projects/{id}/git/branches endpoint exists.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - # Should not return 404 (endpoint not found) - assert response.status_code != 404 - - def test_create_branch_returns_201(self, api_client, test_project_with_issue): - """Test that successful branch creation returns 201.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - assert response.status_code == 201 - - def test_create_branch_returns_branch_name(self, api_client, test_project_with_issue): - """Test that response includes branch_name.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - data = response.json() - assert "branch_name" in data - assert data["branch_name"].startswith("issue-") - - def test_create_branch_includes_issue_number_in_name(self, api_client, test_project_with_issue): - """Test that branch name includes issue number.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - data = response.json() - assert project["issue_number"] in data["branch_name"] - - def test_create_branch_returns_status(self, api_client, test_project_with_issue): - """Test that response includes status as 'active'.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - data = response.json() - assert "status" in data - assert data["status"] == "active" - - def test_create_branch_creates_git_branch(self, api_client, test_project_with_issue): - """Test that branch is actually created in git repository.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - data = response.json() - - # Verify branch exists in git - repo = project["repo"] - branch_names = [b.name for b in repo.branches] - assert data["branch_name"] in branch_names - - def test_create_branch_project_not_found(self, api_client): - """Test that non-existent project returns 404.""" - response = api_client.post( - "/api/projects/99999/git/branches", - json={ - "issue_number": "1.1", - "issue_title": "Test Issue", - }, - ) - assert response.status_code == 404 - - def test_create_branch_requires_issue_number(self, api_client, test_project_with_issue): - """Test that issue_number is required.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_title": project["issue_title"], - }, - ) - assert response.status_code == 422 - - def test_create_branch_requires_issue_title(self, api_client, test_project_with_issue): - """Test that issue_title is required.""" - project = test_project_with_issue - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - }, - ) - assert response.status_code == 422 - - def test_create_duplicate_branch_returns_409_conflict(self, api_client, test_project_with_issue): - """Test that creating duplicate branch returns 409 Conflict.""" - project = test_project_with_issue - - # Create branch first time - api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - - # Try to create same branch again - should return 409 Conflict - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - assert response.status_code == 409 - assert "already exists" in response.json()["detail"].lower() - - def test_create_branch_issue_not_found(self, api_client, test_project_with_git): - """Test that creating branch with non-existent issue returns 404.""" - project = test_project_with_git - response = api_client.post( - f"/api/projects/{project['id']}/git/branches", - json={ - "issue_number": "999.999", - "issue_title": "Non-existent Issue", - }, - ) - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - -# ============================================================================ -# Branch Listing Tests -# ============================================================================ - - -class TestGitBranchListing: - """Test branch listing endpoint: GET /api/projects/{id}/git/branches.""" - - def test_list_branches_endpoint_exists(self, api_client, test_project_with_git): - """Test that GET /api/projects/{id}/git/branches endpoint exists.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/branches") - assert response.status_code != 404 - - def test_list_branches_returns_200(self, api_client, test_project_with_git): - """Test that successful request returns 200.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/branches") - assert response.status_code == 200 - - def test_list_branches_returns_list(self, api_client, test_project_with_git): - """Test that response is a list of branches.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/branches") - data = response.json() - assert "branches" in data - assert isinstance(data["branches"], list) - - def test_list_branches_empty_when_no_branches(self, api_client, test_project_with_git): - """Test that empty list returned when no feature branches exist.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/branches") - data = response.json() - assert data["branches"] == [] - - def test_list_branches_includes_created_branch(self, api_client, test_project_with_issue): - """Test that created branch appears in list.""" - project = test_project_with_issue - - # Create a branch - create_response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - created_branch = create_response.json()["branch_name"] - - # List branches - response = api_client.get(f"/api/projects/{project['project_id']}/git/branches") - data = response.json() - - branch_names = [b["branch_name"] for b in data["branches"]] - assert created_branch in branch_names - - def test_list_branches_project_not_found(self, api_client): - """Test that non-existent project returns 404.""" - response = api_client.get("/api/projects/99999/git/branches") - assert response.status_code == 404 - - -# ============================================================================ -# Branch Details Tests -# ============================================================================ - - -class TestGitBranchDetails: - """Test branch details endpoint: GET /api/projects/{id}/git/branches/{name}.""" - - def test_get_branch_endpoint_exists(self, api_client, test_project_with_issue): - """Test that GET /api/projects/{id}/git/branches/{name} endpoint exists.""" - project = test_project_with_issue - - # Create a branch first - create_response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - branch_name = create_response.json()["branch_name"] - - response = api_client.get( - f"/api/projects/{project['project_id']}/git/branches/{branch_name}" - ) - assert response.status_code != 404 - - def test_get_branch_returns_200(self, api_client, test_project_with_issue): - """Test that successful request returns 200.""" - project = test_project_with_issue - - create_response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - branch_name = create_response.json()["branch_name"] - - response = api_client.get( - f"/api/projects/{project['project_id']}/git/branches/{branch_name}" - ) - assert response.status_code == 200 - - def test_get_branch_includes_required_fields(self, api_client, test_project_with_issue): - """Test that response includes all required fields.""" - project = test_project_with_issue - - create_response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - branch_name = create_response.json()["branch_name"] - - response = api_client.get( - f"/api/projects/{project['project_id']}/git/branches/{branch_name}" - ) - data = response.json() - - assert "id" in data - assert "branch_name" in data - assert "issue_id" in data - assert "status" in data - assert "created_at" in data - - def test_get_branch_not_found(self, api_client, test_project_with_git): - """Test that non-existent branch returns 404.""" - project = test_project_with_git - response = api_client.get( - f"/api/projects/{project['id']}/git/branches/nonexistent-branch" - ) - assert response.status_code == 404 - - -# ============================================================================ -# Commit Creation Tests -# ============================================================================ - - -class TestGitCommitCreation: - """Test commit creation endpoint: POST /api/projects/{id}/git/commit.""" - - def test_commit_endpoint_exists(self, api_client, test_project_with_task): - """Test that POST /api/projects/{id}/git/commit endpoint exists.""" - project = test_project_with_task - - # Create a file to commit - workspace_path = Path(project["workspace_path"]) - test_file = workspace_path / "test.py" - test_file.write_text("# Test file\n") - - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["test.py"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code != 404 - - def test_commit_returns_201(self, api_client, test_project_with_task): - """Test that successful commit returns 201.""" - project = test_project_with_task - - # Create a file to commit - workspace_path = Path(project["workspace_path"]) - test_file = workspace_path / "auth.py" - test_file.write_text("# Authentication module\n") - - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["auth.py"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 201 - - def test_commit_returns_commit_hash(self, api_client, test_project_with_task): - """Test that response includes commit_hash.""" - project = test_project_with_task - - workspace_path = Path(project["workspace_path"]) - test_file = workspace_path / "login.py" - test_file.write_text("# Login endpoint\n") - - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["login.py"], - "agent_id": "backend-worker-001", - }, - ) - data = response.json() - - assert "commit_hash" in data - assert len(data["commit_hash"]) == 40 # Full SHA hash - - def test_commit_returns_commit_message(self, api_client, test_project_with_task): - """Test that response includes commit_message.""" - project = test_project_with_task - - workspace_path = Path(project["workspace_path"]) - test_file = workspace_path / "session.py" - test_file.write_text("# Session management\n") - - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["session.py"], - "agent_id": "backend-worker-001", - }, - ) - data = response.json() - - assert "commit_message" in data - assert isinstance(data["commit_message"], str) - - def test_commit_requires_task_id(self, api_client, test_project_with_task): - """Test that task_id is required.""" - project = test_project_with_task - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "files_modified": ["test.py"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 422 - - def test_commit_requires_files_modified(self, api_client, test_project_with_task): - """Test that files_modified is required.""" - project = test_project_with_task - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 422 - - def test_commit_requires_agent_id(self, api_client, test_project_with_task): - """Test that agent_id is required.""" - project = test_project_with_task - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["test.py"], - }, - ) - assert response.status_code == 422 - - def test_commit_rejects_empty_files_list(self, api_client, test_project_with_task): - """Test that empty files_modified list is rejected.""" - project = test_project_with_task - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": [], - "agent_id": "backend-worker-001", - }, - ) - # Pydantic validation returns 422 for min_length constraint - assert response.status_code == 422 - - def test_commit_project_not_found(self, api_client): - """Test that non-existent project returns 404.""" - response = api_client.post( - "/api/projects/99999/git/commit", - json={ - "task_id": 1, - "files_modified": ["test.py"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 404 - - def test_commit_task_not_found(self, api_client, test_project_with_git): - """Test that non-existent task returns 404.""" - project = test_project_with_git - response = api_client.post( - f"/api/projects/{project['id']}/git/commit", - json={ - "task_id": 99999, - "files_modified": ["test.py"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 404 - - def test_commit_rejects_absolute_paths(self, api_client, test_project_with_task): - """Test that absolute file paths are rejected (security).""" - project = test_project_with_task - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["/etc/passwd"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 400 - assert "absolute" in response.json()["detail"].lower() - - def test_commit_rejects_path_traversal(self, api_client, test_project_with_task): - """Test that path traversal attempts are rejected (security).""" - project = test_project_with_task - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["../../../etc/passwd"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 400 - assert "traversal" in response.json()["detail"].lower() - - def test_commit_rejects_workspace_escape(self, api_client, test_project_with_task): - """Test that paths escaping workspace are rejected (security).""" - project = test_project_with_task - # Use a path that doesn't have '..' but could resolve outside via symlinks - # This tests the commonpath check - response = api_client.post( - f"/api/projects/{project['project_id']}/git/commit", - json={ - "task_id": project["task_id"], - "files_modified": ["subdir/../../../outside.txt"], - "agent_id": "backend-worker-001", - }, - ) - assert response.status_code == 400 - - -# ============================================================================ -# Commit Listing Tests -# ============================================================================ - - -class TestGitCommitListing: - """Test commit listing endpoint: GET /api/projects/{id}/git/commits.""" - - def test_list_commits_endpoint_exists(self, api_client, test_project_with_git): - """Test that GET /api/projects/{id}/git/commits endpoint exists.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/commits") - assert response.status_code != 404 - - def test_list_commits_returns_200(self, api_client, test_project_with_git): - """Test that successful request returns 200.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/commits") - assert response.status_code == 200 - - def test_list_commits_returns_list(self, api_client, test_project_with_git): - """Test that response is a list of commits.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/commits") - data = response.json() - assert "commits" in data - assert isinstance(data["commits"], list) - - def test_list_commits_includes_initial_commit(self, api_client, test_project_with_git): - """Test that initial commit is included in list.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/commits") - data = response.json() - - # Should have at least the initial commit - assert len(data["commits"]) >= 1 - - def test_list_commits_with_limit(self, api_client, test_project_with_git): - """Test that limit parameter works.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/commits?limit=1") - data = response.json() - - assert len(data["commits"]) <= 1 - - def test_list_commits_commit_has_required_fields(self, api_client, test_project_with_git): - """Test that each commit has required fields.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/commits") - data = response.json() - - if data["commits"]: - commit = data["commits"][0] - assert "hash" in commit - assert "short_hash" in commit - assert "message" in commit - assert "author" in commit - assert "timestamp" in commit - - def test_list_commits_project_not_found(self, api_client): - """Test that non-existent project returns 404.""" - response = api_client.get("/api/projects/99999/git/commits") - assert response.status_code == 404 - - -# ============================================================================ -# Git Status Tests -# ============================================================================ - - -class TestGitStatus: - """Test git status endpoint: GET /api/projects/{id}/git/status.""" - - def test_status_endpoint_exists(self, api_client, test_project_with_git): - """Test that GET /api/projects/{id}/git/status endpoint exists.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/status") - assert response.status_code != 404 - - def test_status_returns_200(self, api_client, test_project_with_git): - """Test that successful request returns 200.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/status") - assert response.status_code == 200 - - def test_status_includes_current_branch(self, api_client, test_project_with_git): - """Test that response includes current_branch.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/status") - data = response.json() - - assert "current_branch" in data - assert isinstance(data["current_branch"], str) - - def test_status_includes_is_dirty(self, api_client, test_project_with_git): - """Test that response includes is_dirty flag.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/status") - data = response.json() - - assert "is_dirty" in data - assert isinstance(data["is_dirty"], bool) - - def test_status_includes_file_lists(self, api_client, test_project_with_git): - """Test that response includes file status lists.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/status") - data = response.json() - - assert "modified_files" in data - assert "untracked_files" in data - assert "staged_files" in data - assert isinstance(data["modified_files"], list) - assert isinstance(data["untracked_files"], list) - assert isinstance(data["staged_files"], list) - - def test_status_clean_repo(self, api_client, test_project_with_git): - """Test status on clean repository.""" - project = test_project_with_git - response = api_client.get(f"/api/projects/{project['id']}/git/status") - data = response.json() - - assert data["is_dirty"] is False - assert data["modified_files"] == [] - assert data["untracked_files"] == [] - - def test_status_with_untracked_file(self, api_client, test_project_with_git): - """Test status with untracked files.""" - project = test_project_with_git - - # Create an untracked file - workspace_path = Path(project["workspace_path"]) - new_file = workspace_path / "new_file.py" - new_file.write_text("# New file\n") - - response = api_client.get(f"/api/projects/{project['id']}/git/status") - data = response.json() - - assert data["is_dirty"] is True - assert "new_file.py" in data["untracked_files"] - - def test_status_project_not_found(self, api_client): - """Test that non-existent project returns 404.""" - response = api_client.get("/api/projects/99999/git/status") - assert response.status_code == 404 - - -# ============================================================================ -# Authorization Tests -# ============================================================================ - - -class TestGitApiAuthorization: - """Test authorization for git API endpoints.""" - - def test_create_branch_requires_auth(self, api_client, test_project_with_issue): - """Test that branch creation requires authentication.""" - project = test_project_with_issue - - # Remove auth header - del api_client.headers["Authorization"] - - response = api_client.post( - f"/api/projects/{project['project_id']}/git/branches", - json={ - "issue_number": project["issue_number"], - "issue_title": project["issue_title"], - }, - ) - - # Restore auth header for cleanup - from tests.api.conftest import create_test_jwt_token - api_client.headers["Authorization"] = f"Bearer {create_test_jwt_token()}" - - assert response.status_code == 401 diff --git a/tests/api/test_multi_agent_api.py b/tests/api/test_multi_agent_api.py deleted file mode 100644 index fb22e922..00000000 --- a/tests/api/test_multi_agent_api.py +++ /dev/null @@ -1,656 +0,0 @@ -"""Tests for Multi-Agent Per Project API endpoints. - -Multi-Agent Per Project - Phase 3: API Endpoints -Tests for: -- GET /api/projects/{project_id}/agents -- POST /api/projects/{project_id}/agents -- DELETE /api/projects/{project_id}/agents/{agent_id} -- PATCH /api/projects/{project_id}/agents/{agent_id} -- GET /api/agents/{agent_id}/projects -""" - -import pytest -from fastapi.testclient import TestClient -from codeframe.core.models import AgentMaturity - - -@pytest.mark.usefixtures("api_client") -class TestMultiAgentAPI: - """Test class for multi-agent per project API endpoints.""" - - def test_get_project_agents_empty(self, api_client: TestClient): - """Test getting agents for a project with no agents assigned.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Get agents for project (should be empty) - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() - assert isinstance(agents, list) - assert len(agents) == 0 - - def test_get_project_agents_nonexistent_project(self, api_client: TestClient): - """Test getting agents for a non-existent project.""" - response = api_client.get("/api/projects/99999/agents") - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - def test_assign_agent_to_project_success(self, api_client: TestClient): - """Test successfully assigning an agent to a project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - - # Assign agent to project - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - assert response.status_code == 201 - data = response.json() - assert "assignment_id" in data - assert data["assignment_id"] > 0 - assert "message" in data - assert "backend-001" in data["message"] - assert "primary_backend" in data["message"] - - def test_assign_agent_nonexistent_project(self, api_client: TestClient): - """Test assigning an agent to a non-existent project.""" - response = api_client.post( - "/api/projects/99999/agents", - json={"agent_id": "backend-001", "role": "worker"}, - ) - assert response.status_code == 404 - assert "project" in response.json()["detail"].lower() - - def test_assign_nonexistent_agent(self, api_client: TestClient): - """Test assigning a non-existent agent to a project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Try to assign non-existent agent - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "nonexistent-agent", "role": "worker"}, - ) - assert response.status_code == 404 - assert "agent" in response.json()["detail"].lower() - - def test_assign_agent_already_assigned(self, api_client: TestClient): - """Test assigning an agent that is already assigned to the project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - - # Assign agent to project (first time) - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - assert response.status_code == 201 - - # Try to assign same agent again - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "secondary_backend"}, - ) - assert response.status_code == 400 - assert "already assigned" in response.json()["detail"].lower() - - def test_get_project_agents_with_assignments(self, api_client: TestClient): - """Test getting agents for a project with multiple agents assigned.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create multiple agents - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - db.create_agent( - agent_id="frontend-001", - agent_type="frontend", - provider="claude", - maturity_level=AgentMaturity.D3, - ) - db.create_agent( - agent_id="test-001", - agent_type="test", - provider="claude", - maturity_level=AgentMaturity.D4, - ) - - # Assign all agents to project - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "frontend-001", "role": "primary_frontend"}, - ) - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "test-001", "role": "qa_engineer"}, - ) - - # Get agents for project - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() - assert len(agents) == 3 - - # Verify all agents are present with correct roles - agent_ids = {agent["agent_id"] for agent in agents} - assert agent_ids == {"backend-001", "frontend-001", "test-001"} - - roles = {agent["agent_id"]: agent["role"] for agent in agents} - assert roles["backend-001"] == "primary_backend" - assert roles["frontend-001"] == "primary_frontend" - assert roles["test-001"] == "qa_engineer" - - # Verify all agents are active - for agent in agents: - assert agent["is_active"] is True - assert agent["unassigned_at"] is None - - def test_get_project_agents_active_only(self, api_client: TestClient): - """Test filtering agents by active status.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create agents - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - db.create_agent( - agent_id="backend-002", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - - # Assign both agents - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-002", "role": "secondary_backend"}, - ) - - # Remove one agent - response = api_client.delete(f"/api/projects/{project_id}/agents/backend-002") - assert response.status_code == 204 - - # Get active agents only (default) - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() - assert len(agents) == 1 - assert agents[0]["agent_id"] == "backend-001" - - # Get all agents (including inactive) - response = api_client.get(f"/api/projects/{project_id}/agents?is_active=false") - assert response.status_code == 200 - agents = response.json() - assert len(agents) == 2 - - def test_remove_agent_from_project_success(self, api_client: TestClient): - """Test successfully removing an agent from a project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create and assign an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - - # Remove agent from project - response = api_client.delete(f"/api/projects/{project_id}/agents/backend-001") - assert response.status_code == 204 - - # Verify agent is no longer active - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() - assert len(agents) == 0 - - def test_remove_agent_not_assigned(self, api_client: TestClient): - """Test removing an agent that is not assigned to the project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Try to remove non-assigned agent - response = api_client.delete(f"/api/projects/{project_id}/agents/backend-001") - assert response.status_code == 404 - assert "no active assignment" in response.json()["detail"].lower() - - def test_update_agent_role_success(self, api_client: TestClient): - """Test successfully updating an agent's role on a project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create and assign an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - - # Update agent role - response = api_client.patch( - f"/api/projects/{project_id}/agents/backend-001", - json={"role": "secondary_backend"}, - ) - assert response.status_code == 200 - data = response.json() - assert "message" in data - assert "secondary_backend" in data["message"] - - # Verify role was updated - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() - assert len(agents) == 1 - assert agents[0]["role"] == "secondary_backend" - - def test_update_agent_role_not_assigned(self, api_client: TestClient): - """Test updating role for an agent that is not assigned to the project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Try to update role for non-assigned agent - response = api_client.patch( - f"/api/projects/{project_id}/agents/backend-001", - json={"role": "secondary_backend"}, - ) - assert response.status_code == 404 - assert "no active assignment" in response.json()["detail"].lower() - - def test_get_agent_projects_empty(self, api_client: TestClient): - """Test getting projects for an agent with no assignments.""" - # Create an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - - # Get projects for agent (should be empty) - response = api_client.get("/api/agents/backend-001/projects") - assert response.status_code == 200 - projects = response.json() - assert isinstance(projects, list) - assert len(projects) == 0 - - def test_get_agent_projects_nonexistent_agent(self, api_client: TestClient): - """Test getting projects for a non-existent agent.""" - response = api_client.get("/api/agents/nonexistent-agent/projects") - assert response.status_code == 404 - assert "agent" in response.json()["detail"].lower() - - def test_get_agent_projects_with_assignments(self, api_client: TestClient): - """Test getting projects for an agent with multiple assignments.""" - # Create multiple projects - project_ids = [] - for i in range(3): - response = api_client.post( - "/api/projects", - json={"name": f"Project {i}", "description": f"Description {i}"}, - ) - assert response.status_code == 201 - project_ids.append(response.json()["id"]) - - # Create an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - - # Assign agent to all projects with different roles - roles = ["primary_backend", "secondary_backend", "code_reviewer"] - for project_id, role in zip(project_ids, roles): - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": role}, - ) - - # Get projects for agent - response = api_client.get("/api/agents/backend-001/projects") - assert response.status_code == 200 - projects = response.json() - assert len(projects) == 3 - - # Verify all projects are present with correct roles - project_id_set = {project["project_id"] for project in projects} - assert project_id_set == set(project_ids) - - # Verify roles - project_roles = {project["project_id"]: project["role"] for project in projects} - for project_id, expected_role in zip(project_ids, roles): - assert project_roles[project_id] == expected_role - - # Verify all assignments are active - for project in projects: - assert project["is_active"] is True - assert project["unassigned_at"] is None - - def test_get_agent_projects_active_only(self, api_client: TestClient): - """Test filtering projects by active status.""" - # Create two projects - response = api_client.post( - "/api/projects", - json={"name": "Project 1", "description": "Description 1"}, - ) - assert response.status_code == 201 - project_id_1 = response.json()["id"] - - response = api_client.post( - "/api/projects", - json={"name": "Project 2", "description": "Description 2"}, - ) - assert response.status_code == 201 - project_id_2 = response.json()["id"] - - # Create an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - - # Assign agent to both projects - api_client.post( - f"/api/projects/{project_id_1}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - api_client.post( - f"/api/projects/{project_id_2}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - - # Remove agent from project 2 - response = api_client.delete(f"/api/projects/{project_id_2}/agents/backend-001") - assert response.status_code == 204 - - # Get active projects only (default) - response = api_client.get("/api/agents/backend-001/projects") - assert response.status_code == 200 - projects = response.json() - assert len(projects) == 1 - assert projects[0]["project_id"] == project_id_1 - - # Get all projects (including inactive) - response = api_client.get("/api/agents/backend-001/projects?active_only=false") - assert response.status_code == 200 - projects = response.json() - assert len(projects) == 2 - - def test_agent_reassignment_after_removal(self, api_client: TestClient): - """Test that an agent can be reassigned after being removed.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create an agent - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - - # Assign agent - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - assert response.status_code == 201 - - # Remove agent - response = api_client.delete(f"/api/projects/{project_id}/agents/backend-001") - assert response.status_code == 204 - - # Reassign agent with different role - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "code_reviewer"}, - ) - assert response.status_code == 201 - - # Verify new assignment - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() - assert len(agents) == 1 - assert agents[0]["agent_id"] == "backend-001" - assert agents[0]["role"] == "code_reviewer" - - def test_multiple_agents_different_roles(self, api_client: TestClient): - """Test assigning multiple agents with different roles to same project.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Create multiple agents of same type - from codeframe.ui import server - - db = server.app.state.db - db.create_agent( - agent_id="backend-001", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D2, - ) - db.create_agent( - agent_id="backend-002", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D3, - ) - db.create_agent( - agent_id="backend-003", - agent_type="backend", - provider="claude", - maturity_level=AgentMaturity.D4, - ) - - # Assign agents with different roles - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": "primary_backend"}, - ) - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-002", "role": "secondary_backend"}, - ) - api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-003", "role": "code_reviewer"}, - ) - - # Verify all assignments - response = api_client.get(f"/api/projects/{project_id}/agents") - assert response.status_code == 200 - agents = response.json() - assert len(agents) == 3 - - # Verify correct role assignment - roles = {agent["agent_id"]: agent["role"] for agent in agents} - assert roles["backend-001"] == "primary_backend" - assert roles["backend-002"] == "secondary_backend" - assert roles["backend-003"] == "code_reviewer" - - def test_agent_assignment_request_validation(self, api_client: TestClient): - """Test Pydantic validation for agent assignment request.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Test missing agent_id - response = api_client.post(f"/api/projects/{project_id}/agents", json={"role": "worker"}) - assert response.status_code == 422 # Validation error - - # Test empty agent_id - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "", "role": "worker"}, - ) - assert response.status_code == 422 # Validation error - - # Test empty role - response = api_client.post( - f"/api/projects/{project_id}/agents", - json={"agent_id": "backend-001", "role": ""}, - ) - assert response.status_code == 422 # Validation error - - def test_agent_role_update_request_validation(self, api_client: TestClient): - """Test Pydantic validation for role update request.""" - # Create a project - response = api_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"}, - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Test missing role - response = api_client.patch(f"/api/projects/{project_id}/agents/backend-001", json={}) - assert response.status_code == 422 # Validation error - - # Test empty role - response = api_client.patch( - f"/api/projects/{project_id}/agents/backend-001", json={"role": ""} - ) - assert response.status_code == 422 # Validation error diff --git a/tests/api/test_project_reviews.py b/tests/api/test_project_reviews.py deleted file mode 100644 index e266558f..00000000 --- a/tests/api/test_project_reviews.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Tests for Project-Level Code Reviews API endpoint. - -Tests for GET /api/projects/{project_id}/code-reviews endpoint -which aggregates code review findings across all tasks in a project. -""" - -import pytest -from codeframe.core.models import CodeReview, Severity, ReviewCategory, Task, TaskStatus - - -def get_app(): - """Get the current app instance after module reload.""" - from codeframe.ui.server import app - - return app - - -@pytest.fixture(scope="function") -def project_with_reviews(api_client): - """Create test project with tasks and code review findings. - - Args: - api_client: FastAPI test client from class-scoped fixture - - Returns: - Tuple of (project_id, task_ids, review_ids) - """ - # Create project - project_id = get_app().state.db.create_project( - name="Test Review Project", description="Test project for code reviews API" - ) - - # Create tasks - task1 = Task( - project_id=project_id, - title="Implement user authentication", - description="Add JWT-based authentication", - status=TaskStatus.IN_PROGRESS, - ) - task1_id = get_app().state.db.create_task(task1) - - task2 = Task( - project_id=project_id, - title="Add API rate limiting", - description="Implement rate limiting middleware", - status=TaskStatus.IN_PROGRESS, - ) - task2_id = get_app().state.db.create_task(task2) - - # Create code review findings for task 1 - review1 = CodeReview( - task_id=task1_id, - agent_id="review-001", - project_id=project_id, - file_path="codeframe/api/auth.py", - line_number=45, - severity=Severity.CRITICAL, - category=ReviewCategory.SECURITY, - message="SQL injection vulnerability in login query", - recommendation="Use parameterized queries or ORM", - code_snippet='cursor.execute(f"SELECT * FROM users WHERE username={username}")', - ) - - review2 = CodeReview( - task_id=task1_id, - agent_id="review-001", - project_id=project_id, - file_path="codeframe/api/auth.py", - line_number=67, - severity=Severity.HIGH, - category=ReviewCategory.SECURITY, - message="Password stored in plaintext", - recommendation="Use bcrypt or argon2 for password hashing", - code_snippet="db.save_user(username, password)", - ) - - review3 = CodeReview( - task_id=task1_id, - agent_id="review-001", - project_id=project_id, - file_path="codeframe/api/auth.py", - line_number=89, - severity=Severity.MEDIUM, - category=ReviewCategory.QUALITY, - message="Missing input validation for username", - recommendation="Add length and character validation", - code_snippet='username = request.json["username"]', - ) - - # Create code review findings for task 2 - review4 = CodeReview( - task_id=task2_id, - agent_id="review-001", - project_id=project_id, - file_path="codeframe/api/middleware.py", - line_number=23, - severity=Severity.MEDIUM, - category=ReviewCategory.PERFORMANCE, - message="Rate limiter uses in-memory storage", - recommendation="Consider using Redis for distributed rate limiting", - code_snippet="rate_limits = {}", - ) - - review5 = CodeReview( - task_id=task2_id, - agent_id="review-001", - project_id=project_id, - file_path="codeframe/api/middleware.py", - line_number=45, - severity=Severity.LOW, - category=ReviewCategory.MAINTAINABILITY, - message="Magic number for rate limit threshold", - recommendation="Extract to configuration constant", - code_snippet="if count > 100:", - ) - - review6 = CodeReview( - task_id=task2_id, - agent_id="review-001", - project_id=project_id, - file_path="codeframe/api/middleware.py", - line_number=56, - severity=Severity.INFO, - category=ReviewCategory.STYLE, - message="Line exceeds 88 characters", - recommendation="Break into multiple lines", - code_snippet='response = JSONResponse(status_code=429, content={"error": "Rate limit exceeded"})', - ) - - # Save all reviews - review_ids = [] - for review in [review1, review2, review3, review4, review5, review6]: - review_id = get_app().state.db.save_code_review(review) - review_ids.append(review_id) - - return project_id, [task1_id, task2_id], review_ids - - -@pytest.fixture(scope="function") -def empty_project(api_client): - """Create test project with no code reviews. - - Args: - api_client: FastAPI test client from class-scoped fixture - - Returns: - project_id - """ - project_id = get_app().state.db.create_project( - name="Empty Project", description="Project with no code reviews" - ) - return project_id - - -class TestProjectCodeReviewsEndpoint: - """Test suite for GET /api/projects/{project_id}/code-reviews endpoint.""" - - def test_get_project_code_reviews_success(self, api_client, project_with_reviews): - """Test fetching code reviews for a project.""" - project_id, task_ids, review_ids = project_with_reviews - - response = api_client.get(f"/api/projects/{project_id}/code-reviews") - - assert response.status_code == 200 - data = response.json() - - # Verify response structure (flat structure matching get_task_reviews) - assert "findings" in data - assert "total_count" in data - assert "severity_counts" in data - assert "category_counts" in data - assert "has_blocking_findings" in data - assert "task_id" in data - assert data["task_id"] is None # Project-level aggregate - - # Verify findings count - assert len(data["findings"]) == 6 - assert data["total_count"] == 6 - - # Verify severity counts - assert data["severity_counts"]["critical"] == 1 - assert data["severity_counts"]["high"] == 1 - assert data["severity_counts"]["medium"] == 2 - assert data["severity_counts"]["low"] == 1 - assert data["severity_counts"]["info"] == 1 - - # Verify category counts - assert data["category_counts"]["security"] == 2 - assert data["category_counts"]["performance"] == 1 - assert data["category_counts"]["quality"] == 1 - assert data["category_counts"]["maintainability"] == 1 - assert data["category_counts"]["style"] == 1 - - # Verify blocking issues flag - assert data["has_blocking_findings"] is True # Has critical + high - - def test_get_project_code_reviews_with_severity_filter(self, api_client, project_with_reviews): - """Test filtering reviews by severity.""" - project_id, task_ids, review_ids = project_with_reviews - - # Filter by critical severity - response = api_client.get(f"/api/projects/{project_id}/code-reviews?severity=critical") - - assert response.status_code == 200 - data = response.json() - - # Should only return critical findings - assert len(data["findings"]) == 1 - assert data["findings"][0]["severity"] == "critical" - assert data["total_count"] == 1 - - def test_get_project_code_reviews_multiple_severity_filters( - self, api_client, project_with_reviews - ): - """Test filtering by different severity levels.""" - project_id, task_ids, review_ids = project_with_reviews - - # Test each severity level - severity_expected_counts = {"critical": 1, "high": 1, "medium": 2, "low": 1, "info": 1} - - for severity, expected_count in severity_expected_counts.items(): - response = api_client.get( - f"/api/projects/{project_id}/code-reviews?severity={severity}" - ) - assert response.status_code == 200 - data = response.json() - assert len(data["findings"]) == expected_count - # Verify all findings have the correct severity - for finding in data["findings"]: - assert finding["severity"] == severity - - def test_get_project_code_reviews_empty_project(self, api_client, empty_project): - """Test fetching reviews for project with no reviews.""" - project_id = empty_project - - response = api_client.get(f"/api/projects/{project_id}/code-reviews") - - assert response.status_code == 200 - data = response.json() - - # Verify empty results - assert len(data["findings"]) == 0 - assert data["total_count"] == 0 - assert data["has_blocking_findings"] is False - - # Verify all counts are zero - for severity in ["critical", "high", "medium", "low", "info"]: - assert data["severity_counts"][severity] == 0 - - for category in ["security", "performance", "quality", "maintainability", "style"]: - assert data["category_counts"][category] == 0 - - def test_get_project_code_reviews_invalid_severity(self, api_client, project_with_reviews): - """Test invalid severity filter returns 400.""" - project_id, task_ids, review_ids = project_with_reviews - - response = api_client.get(f"/api/projects/{project_id}/code-reviews?severity=invalid") - - assert response.status_code == 400 - assert "Invalid severity" in response.json()["detail"] - - def test_get_project_code_reviews_nonexistent_project(self, api_client): - """Test fetching reviews for non-existent project returns 404.""" - response = api_client.get("/api/projects/99999/code-reviews") - - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() - - def test_get_project_code_reviews_findings_structure(self, api_client, project_with_reviews): - """Test that findings have correct structure.""" - project_id, task_ids, review_ids = project_with_reviews - - response = api_client.get(f"/api/projects/{project_id}/code-reviews") - assert response.status_code == 200 - data = response.json() - - # Check first finding has all required fields - finding = data["findings"][0] - required_fields = [ - "id", - "task_id", - "agent_id", - "project_id", - "file_path", - "line_number", - "severity", - "category", - "message", - "recommendation", - "code_snippet", - "created_at", - ] - - for field in required_fields: - assert field in finding, f"Missing field: {field}" - - # Verify field types - assert isinstance(finding["id"], int) - assert isinstance(finding["task_id"], int) - assert isinstance(finding["agent_id"], str) - assert isinstance(finding["project_id"], int) - assert finding["project_id"] == project_id - - def test_get_project_code_reviews_no_blocking_issues(self, api_client, empty_project): - """Test project with only low/info findings has no blocking issues.""" - project_id = empty_project - - # Create task with only low severity findings - task = Task( - project_id=project_id, - title="Minor refactoring", - description="Clean up code", - status=TaskStatus.IN_PROGRESS, - ) - task_id = get_app().state.db.create_task(task) - - # Add only low severity review - review = CodeReview( - task_id=task_id, - agent_id="review-001", - project_id=project_id, - file_path="test.py", - line_number=10, - severity=Severity.LOW, - category=ReviewCategory.STYLE, - message="Minor style issue", - recommendation="Fix formatting", - ) - get_app().state.db.save_code_review(review) - - response = api_client.get(f"/api/projects/{project_id}/code-reviews") - assert response.status_code == 200 - data = response.json() - - # Should not have blocking issues (only low severity) - assert data["has_blocking_findings"] is False diff --git a/tests/api/test_projects_api_progress.py b/tests/api/test_projects_api_progress.py deleted file mode 100644 index 215ceee9..00000000 --- a/tests/api/test_projects_api_progress.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -Test that /api/projects endpoint returns progress metrics. - -This test was created in response to production bug cf-46 where the frontend -Dashboard expected a `progress` field with `completed_tasks`, `total_tasks`, -and `percentage`, but the API was only returning raw database rows. - -RED Phase: This test should FAIL initially, demonstrating the bug. -""" - -from codeframe.persistence.database import Database -from codeframe.core.models import TaskStatus, Issue - - -def test_list_projects_includes_progress_metrics(): - """ - Test that list_projects() returns progress field with task completion metrics. - - This is the RED phase test that demonstrates Bug 1 from cf-46. - The test will FAIL because list_projects() currently doesn't calculate progress. - """ - # Given: A database with a project - db = Database(":memory:") - db.initialize() - - project_id = db.create_project("Test Project", "Test Project project") - - # And: An issue for that project - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Test Issue", - description="Test issue for progress calculation", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - # And: 5 tasks for the issue (3 completed, 2 pending) - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.1", - parent_issue_number="1.1", - title="Task 1", - description="First task", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.2", - parent_issue_number="1.1", - title="Task 2", - description="Second task", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.3", - parent_issue_number="1.1", - title="Task 3", - description="Third task", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.4", - parent_issue_number="1.1", - title="Task 4", - description="Fourth task", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.5", - parent_issue_number="1.1", - title="Task 5", - description="Fifth task", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch the project list - projects = db.list_projects() - - # Then: Should return exactly one project - assert len(projects) == 1 - project = projects[0] - - # And: Project should have basic fields - assert project["id"] == project_id - assert project["name"] == "Test Project" - - # And: Project MUST have a progress field (this will FAIL initially) - assert "progress" in project, "Missing 'progress' field - this is Bug 1 from cf-46" - - # And: Progress field MUST have all required sub-fields - assert "completed_tasks" in project["progress"], "Missing 'progress.completed_tasks'" - assert "total_tasks" in project["progress"], "Missing 'progress.total_tasks'" - assert "percentage" in project["progress"], "Missing 'progress.percentage'" - - # And: The metrics should be calculated correctly - # 3 out of 5 tasks completed = 60% - assert ( - project["progress"]["completed_tasks"] == 3 - ), f"Expected 3 completed tasks, got {project['progress']['completed_tasks']}" - assert ( - project["progress"]["total_tasks"] == 5 - ), f"Expected 5 total tasks, got {project['progress']['total_tasks']}" - assert ( - project["progress"]["percentage"] == 60.0 - ), f"Expected 60.0% completion, got {project['progress']['percentage']}" - - -def test_list_projects_progress_with_no_tasks(): - """ - Test progress calculation for a project with no tasks. - - Edge case: A new project with no issues or tasks should have 0/0 progress. - """ - # Given: A database with a project that has no tasks - db = Database(":memory:") - db.initialize() - - db.create_project("Empty Project", "Empty Project project") - - # When: We fetch the project list - projects = db.list_projects() - - # Then: Progress should show 0 tasks, 0% completion - assert len(projects) == 1 - project = projects[0] - - assert "progress" in project - assert project["progress"]["completed_tasks"] == 0 - assert project["progress"]["total_tasks"] == 0 - assert project["progress"]["percentage"] == 0.0 - - -def test_list_projects_progress_with_all_completed(): - """ - Test progress calculation when all tasks are completed. - - Edge case: A project with all tasks completed should show 100%. - """ - # Given: A database with a project - db = Database(":memory:") - db.initialize() - - project_id = db.create_project("Completed Project", "Completed Project project") - - # And: An issue with all tasks completed - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Completed Issue", - description="All tasks done", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - # And: 3 completed tasks - for i in range(3): - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"1.1.{i+1}", - parent_issue_number="1.1", - title=f"Task {i+1}", - description=f"Task {i+1}", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch the project list - projects = db.list_projects() - - # Then: Progress should show 100% completion - assert len(projects) == 1 - project = projects[0] - - assert "progress" in project - assert project["progress"]["completed_tasks"] == 3 - assert project["progress"]["total_tasks"] == 3 - assert project["progress"]["percentage"] == 100.0 - - -def test_list_projects_progress_multiple_projects(): - """ - Test that progress is calculated correctly for each project independently. - """ - # Given: A database with two projects - db = Database(":memory:") - db.initialize() - - # Project 1: 50% complete (1 of 2 tasks) - project1_id = db.create_project("Project Alpha", "Project Alpha project") - issue1 = Issue( - project_id=project1_id, - issue_number="1.1", - title="Issue 1", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue1_id = db.create_issue(issue1) - - db.create_task_with_issue( - project_id=project1_id, - issue_id=issue1_id, - task_number="1.1.1", - parent_issue_number="1.1", - title="Task 1", - description="Task 1", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - db.create_task_with_issue( - project_id=project1_id, - issue_id=issue1_id, - task_number="1.1.2", - parent_issue_number="1.1", - title="Task 2", - description="Task 2", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # Project 2: 75% complete (3 of 4 tasks) - project2_id = db.create_project("Project Beta", "Project Beta project") - issue2 = Issue( - project_id=project2_id, - issue_number="1.1", - title="Issue 1", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue2_id = db.create_issue(issue2) - - for i in range(4): - status = TaskStatus.COMPLETED if i < 3 else TaskStatus.PENDING - db.create_task_with_issue( - project_id=project2_id, - issue_id=issue2_id, - task_number=f"1.1.{i+1}", - parent_issue_number="1.1", - title=f"Task {i+1}", - description=f"Task {i+1}", - status=status, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch the project list - projects = db.list_projects() - - # Then: Should have 2 projects with independent progress - assert len(projects) == 2 - - # Find projects by name (order may vary) - alpha = next(p for p in projects if p["name"] == "Project Alpha") - beta = next(p for p in projects if p["name"] == "Project Beta") - - # Project Alpha: 50% (1/2) - assert alpha["progress"]["completed_tasks"] == 1 - assert alpha["progress"]["total_tasks"] == 2 - assert alpha["progress"]["percentage"] == 50.0 - - # Project Beta: 75% (3/4) - assert beta["progress"]["completed_tasks"] == 3 - assert beta["progress"]["total_tasks"] == 4 - assert beta["progress"]["percentage"] == 75.0 diff --git a/tests/blockers/test_blockers.py b/tests/blockers/test_blockers.py deleted file mode 100644 index 540d4641..00000000 --- a/tests/blockers/test_blockers.py +++ /dev/null @@ -1,522 +0,0 @@ -"""Unit tests for blocker database operations. - -Tests T050-T054 from Phase 9: Testing & Validation - -Note: Some concurrency tests are intentionally opt-in because Python's sqlite3 -module + a shared connection can behave inconsistently under thread scheduling -differences (common on CI runners). -""" - -import os - -import pytest -import pytest_asyncio -import time -import threading -from datetime import datetime, timedelta -from codeframe.persistence.database import Database -from codeframe.core.models import BlockerType, TaskStatus - - -@pytest_asyncio.fixture -async def db(): - """Create in-memory database for testing.""" - import sqlite3 - - # Use check_same_thread=False for threading tests - database = Database(":memory:") - database.initialize() - - # Enable SQLite threading mode for concurrent access - if database.conn: - database.conn.isolation_level = None # Autocommit mode - - yield database - - # Ensure all pending operations complete - if database.conn: - try: - database.conn.execute("PRAGMA optimize") - except sqlite3.Error: - pass - database.close() - - -@pytest_asyncio.fixture -async def sample_project(db): - """Create a sample project for testing.""" - project_id = db.create_project( - name="Test Project", repo_path="/tmp/test", description="Test project for blocker tests" - ) - return project_id - - -@pytest_asyncio.fixture -async def sample_task(db, sample_project): - """Create a sample task for testing.""" - # First create an issue - issue_id = db.create_issue( - { - "project_id": sample_project, - "issue_number": "1.0", - "title": "Test Issue", - "description": "Test issue for blocker tests", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - - # Then create a task - task_id = db.create_task_with_issue( - project_id=sample_project, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Test Task", - description="Test task for blocker tests", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - return task_id - - -class TestCreateBlocker: - """Test T050: Unit test for create_blocker() database method.""" - - def test_create_blocker_sync(self, db, sample_task, sample_project): - """Test creating a SYNC blocker.""" - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Should I use SQLite or PostgreSQL?", - ) - - assert blocker_id > 0 - - # Verify blocker was created correctly - blocker = db.get_blocker(blocker_id) - assert blocker is not None - assert blocker["agent_id"] == "backend-worker-001" - assert blocker["task_id"] == sample_task - assert blocker["blocker_type"] == BlockerType.SYNC - assert blocker["question"] == "Should I use SQLite or PostgreSQL?" - assert blocker["status"] == "PENDING" - assert blocker["answer"] is None - assert blocker["resolved_at"] is None - assert blocker["created_at"] is not None - - def test_create_blocker_async(self, db, sample_task, sample_project): - """Test creating an ASYNC blocker.""" - blocker_id = db.create_blocker( - agent_id="frontend-worker-002", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.ASYNC, - question="Should I use Tailwind or CSS Modules?", - ) - - blocker = db.get_blocker(blocker_id) - assert blocker["blocker_type"] == BlockerType.ASYNC - assert blocker["status"] == "PENDING" - - def test_create_blocker_without_task(self, db, sample_project): - """Test creating a blocker without an associated task.""" - blocker_id = db.create_blocker( - agent_id="test-agent-003", - project_id=sample_project, - task_id=None, - blocker_type=BlockerType.SYNC, - question="General question without task", - ) - - blocker = db.get_blocker(blocker_id) - assert blocker["task_id"] is None - assert blocker["status"] == "PENDING" - - def test_create_blocker_with_long_question(self, db, sample_task, sample_project): - """Test creating a blocker with maximum length question.""" - long_question = "A" * 2000 # Max 2000 chars - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question=long_question, - ) - - blocker = db.get_blocker(blocker_id) - assert len(blocker["question"]) == 2000 - - -class TestResolveBlocker: - """Test T051: Unit test for resolve_blocker() database method.""" - - def test_resolve_blocker_success(self, db, sample_task, sample_project): - """Test successfully resolving a blocker.""" - # Create blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="What API key should I use?", - ) - - # Resolve blocker - success = db.resolve_blocker(blocker_id, "Use key: sk-test-123") - assert success is True - - # Verify resolution - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert blocker["answer"] == "Use key: sk-test-123" - assert blocker["resolved_at"] is not None - - def test_resolve_blocker_not_found(self, db): - """Test resolving a non-existent blocker.""" - success = db.resolve_blocker(99999, "Some answer") - assert success is False - - def test_resolve_blocker_with_long_answer(self, db, sample_task, sample_project): - """Test resolving with maximum length answer.""" - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question?", - ) - - long_answer = "B" * 5000 # Max 5000 chars - success = db.resolve_blocker(blocker_id, long_answer) - assert success is True - - blocker = db.get_blocker(blocker_id) - assert len(blocker["answer"]) == 5000 - - -class TestDuplicateResolution: - """Test T052: Unit test for resolve_blocker() twice (duplicate resolution).""" - - def test_resolve_blocker_twice(self, db, sample_task, sample_project): - """Test that resolving an already-resolved blocker fails.""" - # Create blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question?", - ) - - # First resolution - success1 = db.resolve_blocker(blocker_id, "First answer") - assert success1 is True - - # Second resolution (should fail) - success2 = db.resolve_blocker(blocker_id, "Second answer") - assert success2 is False - - # Verify first answer persists - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert blocker["answer"] == "First answer" - - def test_concurrent_resolution_race_condition(self, db, sample_task, sample_project): - """Test concurrent resolution attempts (race condition). - - This is intentionally opt-in because the current implementation uses a - single shared sqlite3 connection, and concurrent use of a single - connection across threads can be flaky across environments. - - Enable explicitly when working on DB concurrency hardening: - CODEFRAME_RUN_CONCURRENCY_TESTS=1 pytest -k concurrent_resolution_race_condition - """ - if os.getenv("CODEFRAME_RUN_CONCURRENCY_TESTS") != "1": - pytest.skip( - "Opt-in test: set CODEFRAME_RUN_CONCURRENCY_TESTS=1 to run. " - "(Shared sqlite3 connection concurrency is flaky on CI.)" - ) - - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question?", - ) - - # Simulate concurrent resolutions using threading - results = [] - lock = threading.Lock() - - def resolve_a(): - time.sleep(0.01) - result = db.resolve_blocker(blocker_id, "Answer A") - with lock: - results.append(result) - - def resolve_b(): - time.sleep(0.01) - result = db.resolve_blocker(blocker_id, "Answer B") - with lock: - results.append(result) - - thread_a = threading.Thread(target=resolve_a) - thread_b = threading.Thread(target=resolve_b) - - thread_a.start() - thread_b.start() - thread_a.join(timeout=2.0) - thread_b.join(timeout=2.0) - - # Wait a bit for any pending database operations - time.sleep(0.1) - - # Exactly one should succeed - successes = sum(1 for r in results if r is True) - assert successes == 1, f"Expected 1 success, got {successes}. Results: {results}" - - # Verify only one answer was stored - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert blocker["answer"] in ["Answer A", "Answer B"] - - -class TestGetPendingBlocker: - """Test T053: Unit test for get_pending_blocker() agent polling.""" - - def test_get_pending_blocker_exists(self, db, sample_task, sample_project): - """Test retrieving a pending blocker for an agent.""" - # Create blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question?", - ) - - # Agent polls for blocker - blocker = db.get_pending_blocker("backend-worker-001") - assert blocker is not None - assert blocker["id"] == blocker_id - assert blocker["status"] == "PENDING" - - def test_get_pending_blocker_none(self, db): - """Test polling when no blocker exists.""" - blocker = db.get_pending_blocker("nonexistent-agent") - assert blocker is None - - def test_get_pending_blocker_after_resolution(self, db, sample_task, sample_project): - """Test that resolved blockers are not returned.""" - # Create and resolve blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question?", - ) - db.resolve_blocker(blocker_id, "Answer") - - # Agent should not see resolved blocker - blocker = db.get_pending_blocker("backend-worker-001") - assert blocker is None - - def test_get_pending_blocker_oldest_first(self, db, sample_task, sample_project): - """Test that oldest blocker is returned first.""" - # Create multiple blockers - id1 = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="First question", - ) - time.sleep(0.1) # Ensure different timestamps - db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Second question", - ) - - # Should return oldest blocker first - blocker = db.get_pending_blocker("backend-worker-001") - assert blocker["id"] == id1 - - -class TestExpireStaleBlockers: - """Test T054: Unit test for expire_stale_blockers().""" - - def test_expire_stale_blockers_none_stale(self, db, sample_task, sample_project): - """Test expiration when no blockers are stale.""" - # Create recent blocker - db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Recent question", - ) - - # Run expiration - expired_ids = db.expire_stale_blockers(hours=24) - assert len(expired_ids) == 0 - - def test_expire_stale_blockers_one_stale(self, db, sample_task, sample_project): - """Test expiring a single stale blocker.""" - # Create blocker with old timestamp (manual SQL update) - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Old question", - ) - - # Manually set created_at to 25 hours ago - old_timestamp = datetime.now() - timedelta(hours=25) - cursor = db.conn.cursor() - cursor.execute( - "UPDATE blockers SET created_at = ? WHERE id = ?", - (old_timestamp.isoformat(), blocker_id), - ) - db.conn.commit() - - # Run expiration - expired_ids = db.expire_stale_blockers(hours=24) - assert blocker_id in expired_ids - - # Verify status changed - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "EXPIRED" - - def test_expire_stale_blockers_multiple(self, db, sample_task, sample_project): - """Test expiring multiple stale blockers.""" - # Create 3 stale blockers - blocker_ids = [] - for i in range(3): - blocker_id = db.create_blocker( - agent_id=f"agent-{i}", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question=f"Question {i}", - ) - blocker_ids.append(blocker_id) - - # Set to 25 hours ago - old_timestamp = datetime.now() - timedelta(hours=25) - cursor = db.conn.cursor() - cursor.execute( - "UPDATE blockers SET created_at = ? WHERE id = ?", - (old_timestamp.isoformat(), blocker_id), - ) - db.conn.commit() - - # Run expiration - expired_ids = db.expire_stale_blockers(hours=24) - assert len(expired_ids) == 3 - assert all(bid in expired_ids for bid in blocker_ids) - - def test_expire_stale_blockers_skips_resolved(self, db, sample_task, sample_project): - """Test that resolved blockers are not expired.""" - # Create and resolve a stale blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question", - ) - db.resolve_blocker(blocker_id, "Answer") - - # Set to 25 hours ago - old_timestamp = datetime.now() - timedelta(hours=25) - cursor = db.conn.cursor() - cursor.execute( - "UPDATE blockers SET created_at = ? WHERE id = ?", - (old_timestamp.isoformat(), blocker_id), - ) - db.conn.commit() - - # Run expiration - expired_ids = db.expire_stale_blockers(hours=24) - assert blocker_id not in expired_ids - - # Verify still resolved - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - - -class TestBlockerListWithEnrichment: - """Test list_blockers() with enrichment (supplemental).""" - - def test_list_blockers_empty(self, db, sample_project): - """Test listing blockers when none exist.""" - response = db.list_blockers(sample_project) - assert response["total"] == 0 - assert response["pending_count"] == 0 - assert response["sync_count"] == 0 - assert len(response["blockers"]) == 0 - - def test_list_blockers_with_data(self, db, sample_project, sample_task): - """Test listing blockers with data.""" - # Create mixed blockers - db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="SYNC question", - ) - blocker2_id = db.create_blocker( - agent_id="backend-worker-002", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.ASYNC, - question="ASYNC question", - ) - db.resolve_blocker(blocker2_id, "Answer") - - # List all blockers - response = db.list_blockers(sample_project) - assert response["total"] == 2 - assert response["pending_count"] == 1 - assert response["sync_count"] == 1 - - def test_list_blockers_filter_by_status(self, db, sample_project, sample_task): - """Test filtering blockers by status.""" - blocker1_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question 1", - ) - blocker2_id = db.create_blocker( - agent_id="backend-worker-002", - project_id=sample_project, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question 2", - ) - db.resolve_blocker(blocker2_id, "Answer") - - # Filter by PENDING - response = db.list_blockers(sample_project, status="PENDING") - assert response["total"] == 1 - assert response["blockers"][0]["id"] == blocker1_id - - # Filter by RESOLVED - response = db.list_blockers(sample_project, status="RESOLVED") - assert response["total"] == 1 - assert response["blockers"][0]["id"] == blocker2_id diff --git a/tests/conftest.py b/tests/conftest.py index ca473ae6..4d9583ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,21 +13,11 @@ # NOTE: collect_ignore must be at module level but can come after imports collect_ignore = [ # v1 API tests (use app.state.db) - "api/test_api_issues.py", - "api/test_api_metrics.py", - "api/test_api_prd.py", - "api/test_api_session.py", - "api/test_blocker_resolution_api.py", "api/test_chat_api.py", "api/test_discovery_restart.py", - "api/test_endpoints_database.py", "api/test_generate_tasks_endpoint.py", - "api/test_git_api.py", "api/test_health_endpoint.py", - "api/test_multi_agent_api.py", "api/test_project_creation_api.py", - "api/test_project_reviews.py", - "api/test_projects_api_progress.py", "api/test_schedule_api.py", "api/test_templates_api.py", "api/test_workspace_cleanup.py", @@ -37,8 +27,6 @@ "auth/test_api_key_endpoints.py", "auth/test_authorization_integration.py", "auth/test_dual_auth.py", - # v1 persistence tests - "persistence/test_server_database.py", ] diff --git a/tests/context/test_checkpoint_restore.py b/tests/context/test_checkpoint_restore.py deleted file mode 100644 index 1d550fc4..00000000 --- a/tests/context/test_checkpoint_restore.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Unit tests for checkpoint creation and retrieval (T049). - -Tests the checkpoint functionality: -- Creating checkpoints with JSON data -- Listing checkpoints for an agent -- Checkpoint metadata (items_count, token_count, etc.) - -Part of 007-context-management Phase 6 (US4 - Flash Save). -""" - -import pytest -import tempfile -import json -from pathlib import Path -from datetime import datetime, UTC - -from codeframe.persistence.database import Database - - -@pytest.fixture -def temp_db(): - """Create temporary database for testing.""" - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: - db_path = f.name - - db = Database(db_path) - db.initialize() - - yield db - - db.close() - # Cleanup - Path(db_path).unlink(missing_ok=True) - - -@pytest.fixture -def test_project(temp_db): - """Create a test project for context items.""" - project_id = temp_db.create_project( - name="test-project", description="Test project for checkpoint restore", workspace_path="" - ) - return project_id - - -class TestCheckpointRestore: - """Unit tests for checkpoint creation and retrieval.""" - - def test_create_checkpoint_with_data(self, temp_db, test_project): - """Test that checkpoint stores JSON state correctly.""" - agent_id = "test-agent-checkpoint-001" - - # Create checkpoint data (JSON-serializable state) - checkpoint_data = { - "context_items": [ - {"id": 1, "content": "Task 1", "tier": "HOT"}, - {"id": 2, "content": "Task 2", "tier": "WARM"}, - {"id": 3, "content": "Task 3", "tier": "COLD"}, - ], - "metadata": { - "timestamp": datetime.now(UTC).isoformat(), - "reason": "flash_save_triggered", - }, - } - - # ACT: Create checkpoint - checkpoint_id = temp_db.create_checkpoint( - agent_id=agent_id, - checkpoint_data=json.dumps(checkpoint_data), - items_count=10, - items_archived=5, - hot_items_retained=3, - token_count=5000, - ) - - # ASSERT: Checkpoint created - assert checkpoint_id > 0 - - # Retrieve and verify checkpoint - checkpoint = temp_db.get_checkpoint(checkpoint_id) - assert checkpoint is not None - assert checkpoint["agent_id"] == agent_id - - # Verify JSON data can be deserialized - retrieved_data = json.loads(checkpoint["checkpoint_data"]) - assert "context_items" in retrieved_data - assert len(retrieved_data["context_items"]) == 3 - assert retrieved_data["metadata"]["reason"] == "flash_save_triggered" - - def test_list_checkpoints_for_agent(self, temp_db, test_project): - """Test that pagination works for listing checkpoints.""" - agent_id = "test-agent-checkpoint-002" - - # Create multiple checkpoints - checkpoint_ids = [] - for i in range(15): - checkpoint_data = {"checkpoint_number": i, "items": []} - - checkpoint_id = temp_db.create_checkpoint( - agent_id=agent_id, - checkpoint_data=json.dumps(checkpoint_data), - items_count=10 + i, - items_archived=5 + i, - hot_items_retained=3, - token_count=5000 + (i * 100), - ) - checkpoint_ids.append(checkpoint_id) - - # ACT: List checkpoints with default limit (10) - checkpoints_page1 = temp_db.list_checkpoints(agent_id, limit=10) - - # ASSERT: Returns first 10 checkpoints (most recent first) - assert len(checkpoints_page1) == 10 - - # ACT: List with limit=5 - checkpoints_page2 = temp_db.list_checkpoints(agent_id, limit=5) - - # ASSERT: Returns 5 most recent - assert len(checkpoints_page2) == 5 - - # Verify all returned checkpoints belong to this agent - assert all(cp["agent_id"] == agent_id for cp in checkpoints_page2) - - # Verify IDs are from our created set - returned_ids = [cp["id"] for cp in checkpoints_page2] - assert all(cid in checkpoint_ids for cid in returned_ids) - - def test_checkpoint_includes_metrics(self, temp_db, test_project): - """Test that checkpoint includes metrics (items_count, token_count, etc.).""" - agent_id = "test-agent-checkpoint-003" - - # Create checkpoint with specific metrics - checkpoint_data = {"state": "saved"} - checkpoint_id = temp_db.create_checkpoint( - agent_id=agent_id, - checkpoint_data=json.dumps(checkpoint_data), - items_count=50, - items_archived=20, - hot_items_retained=15, - token_count=12000, - ) - - # ACT: Retrieve checkpoint - checkpoint = temp_db.get_checkpoint(checkpoint_id) - - # ASSERT: Metrics are present - assert checkpoint["items_count"] == 50 - assert checkpoint["items_archived"] == 20 - assert checkpoint["hot_items_retained"] == 15 - assert checkpoint["token_count"] == 12000 - - # Verify created_at timestamp exists - assert "created_at" in checkpoint - assert checkpoint["created_at"] is not None - - def test_list_checkpoints_for_nonexistent_agent(self, temp_db): - """Test listing checkpoints for agent with no checkpoints.""" - agent_id = "nonexistent-agent" - - # ACT: List checkpoints - checkpoints = temp_db.list_checkpoints(agent_id, limit=10) - - # ASSERT: Returns empty list - assert checkpoints == [] - assert len(checkpoints) == 0 - - def test_get_nonexistent_checkpoint(self, temp_db): - """Test retrieving checkpoint that doesn't exist.""" - # ACT: Get checkpoint with invalid ID - checkpoint = temp_db.get_checkpoint(999999) - - # ASSERT: Returns None - assert checkpoint is None diff --git a/tests/context/test_tier_filtering.py b/tests/context/test_tier_filtering.py deleted file mode 100644 index 3230a3df..00000000 --- a/tests/context/test_tier_filtering.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Tests for tier-based context filtering (T038). - -Tests the database list_context_items() method with tier filtering: -- Filter by specific tier (HOT, WARM, COLD) -- Verify correct items returned -- Test tier=None returns all items - -Part of 007-context-management Phase 5 (US3 - Automatic Tier Assignment). - -NOTE: These tests use Database directly, not WorkerAgent. -WorkerAgent no longer accepts project_id in __init__(). -""" - -import pytest -import tempfile -from pathlib import Path - -from codeframe.persistence.database import Database -from codeframe.core.models import ContextItemType - - -@pytest.fixture -def temp_db(): - """Create temporary database for testing.""" - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: - db_path = f.name - - db = Database(db_path) - db.initialize() - - yield db - - db.close() - # Cleanup - Path(db_path).unlink(missing_ok=True) - - -@pytest.fixture -def test_project(temp_db): - """Create a test project for context items.""" - project_id = temp_db.create_project( - name="test-project", description="Test project for context management", workspace_path="" - ) - return project_id - - -class TestTierFiltering: - """Test tier-based filtering in list_context_items().""" - - def test_filter_by_hot_tier(self, temp_db, test_project): - """Test filtering returns only HOT tier items.""" - agent_id = "test-agent-hot" - - # Create items with different scores (will auto-assign tiers) - # HOT items (score >= 0.8) - hot_item_1 = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.TASK.value, - content="Fresh critical task", - ) - - # Manually set high score to ensure HOT tier - cursor = temp_db.conn.cursor() - cursor.execute( - "UPDATE context_items SET importance_score = 0.9, current_tier = 'hot' WHERE id = ?", - (hot_item_1,), - ) - temp_db.conn.commit() - - # WARM item (score 0.4-0.8) - warm_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.CODE.value, - content="Some code", - ) - cursor.execute( - "UPDATE context_items SET importance_score = 0.6, current_tier = 'warm' WHERE id = ?", - (warm_item,), - ) - temp_db.conn.commit() - - # ACT: Filter by HOT tier - hot_items = temp_db.list_context_items( - project_id=test_project, agent_id=agent_id, tier="HOT" - ) - - # ASSERT: Only HOT items returned - assert len(hot_items) == 1 - assert hot_items[0]["id"] == hot_item_1 - assert hot_items[0]["current_tier"] == "hot" - - def test_filter_by_warm_tier(self, temp_db, test_project): - """Test filtering returns only WARM tier items.""" - agent_id = "test-agent-warm" - - # Create HOT item - hot_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.TASK.value, - content="Critical task", - ) - cursor = temp_db.conn.cursor() - cursor.execute( - "UPDATE context_items SET importance_score = 0.9, current_tier = 'hot' WHERE id = ?", - (hot_item,), - ) - - # Create WARM items - warm_item_1 = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.CODE.value, - content="Some code", - ) - cursor.execute( - "UPDATE context_items SET importance_score = 0.6, current_tier = 'warm' WHERE id = ?", - (warm_item_1,), - ) - - warm_item_2 = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.ERROR.value, - content="Error log", - ) - cursor.execute( - "UPDATE context_items SET importance_score = 0.5, current_tier = 'warm' WHERE id = ?", - (warm_item_2,), - ) - temp_db.conn.commit() - - # ACT: Filter by WARM tier - warm_items = temp_db.list_context_items( - project_id=test_project, agent_id=agent_id, tier="WARM" - ) - - # ASSERT: Only WARM items returned - assert len(warm_items) == 2 - warm_ids = [item["id"] for item in warm_items] - assert warm_item_1 in warm_ids - assert warm_item_2 in warm_ids - assert all(item["current_tier"] == "warm" for item in warm_items) - - def test_filter_by_cold_tier(self, temp_db, test_project): - """Test filtering returns only COLD tier items.""" - agent_id = "test-agent-cold" - - # Create HOT item - hot_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.TASK.value, - content="Critical task", - ) - cursor = temp_db.conn.cursor() - cursor.execute( - "UPDATE context_items SET importance_score = 0.9, current_tier = 'hot' WHERE id = ?", - (hot_item,), - ) - - # Create COLD item - cold_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.PRD_SECTION.value, - content="Old PRD section", - ) - cursor.execute( - "UPDATE context_items SET importance_score = 0.2, current_tier = 'cold' WHERE id = ?", - (cold_item,), - ) - temp_db.conn.commit() - - # ACT: Filter by COLD tier - cold_items = temp_db.list_context_items( - project_id=test_project, agent_id=agent_id, tier="COLD" - ) - - # ASSERT: Only COLD items returned - assert len(cold_items) == 1 - assert cold_items[0]["id"] == cold_item - assert cold_items[0]["current_tier"] == "cold" - - def test_tier_none_returns_all_items(self, temp_db, test_project): - """Test that tier=None returns all items regardless of tier.""" - agent_id = "test-agent-all" - - # Create items in all tiers - hot_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.TASK.value, - content="HOT item", - ) - cursor = temp_db.conn.cursor() - cursor.execute( - "UPDATE context_items SET importance_score = 0.9, current_tier = 'hot' WHERE id = ?", - (hot_item,), - ) - - warm_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.CODE.value, - content="WARM item", - ) - cursor.execute( - "UPDATE context_items SET importance_score = 0.6, current_tier = 'warm' WHERE id = ?", - (warm_item,), - ) - - cold_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.PRD_SECTION.value, - content="COLD item", - ) - cursor.execute( - "UPDATE context_items SET importance_score = 0.2, current_tier = 'cold' WHERE id = ?", - (cold_item,), - ) - temp_db.conn.commit() - - # ACT: Get all items (tier=None) - all_items = temp_db.list_context_items( - project_id=test_project, agent_id=agent_id, tier=None - ) - - # ASSERT: All 3 items returned - assert len(all_items) == 3 - all_ids = [item["id"] for item in all_items] - assert hot_item in all_ids - assert warm_item in all_ids - assert cold_item in all_ids - - def test_empty_tier_filter(self, temp_db, test_project): - """Test filtering by tier with no matching items.""" - agent_id = "test-agent-empty" - - # Create only HOT item - hot_item = temp_db.create_context_item( - project_id=test_project, - agent_id=agent_id, - item_type=ContextItemType.TASK.value, - content="HOT item", - ) - cursor = temp_db.conn.cursor() - cursor.execute( - "UPDATE context_items SET importance_score = 0.9, current_tier = 'hot' WHERE id = ?", - (hot_item,), - ) - temp_db.conn.commit() - - # ACT: Filter by COLD tier (no COLD items exist) - cold_items = temp_db.list_context_items( - project_id=test_project, agent_id=agent_id, tier="COLD" - ) - - # ASSERT: Empty list returned - assert len(cold_items) == 0 - assert cold_items == [] diff --git a/tests/debug/test_fixture_debug.py b/tests/debug/test_fixture_debug.py deleted file mode 100644 index a477a300..00000000 --- a/tests/debug/test_fixture_debug.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Debug test to isolate which fixture is hanging. -""" - -import pytest -import tempfile -from codeframe.persistence.database import Database - - -@pytest.fixture -def db_debug(): - """Create test database with debug output.""" - print("\n🔵 FIXTURE DEBUG: Creating database...") - db = Database(":memory:") - print("🔵 FIXTURE DEBUG: Database created, initializing schema...") - db.initialize() - print("🔵 FIXTURE DEBUG: Database initialized ✅") - yield db - print("🔵 FIXTURE DEBUG: Closing database...") - db.close() - print("🔵 FIXTURE DEBUG: Database closed ✅") - - -@pytest.fixture -def temp_project_dir_debug(): - """Create temporary project directory with debug output.""" - print("\n🟡 FIXTURE DEBUG: Creating temp directory...") - with tempfile.TemporaryDirectory() as tmpdir: - print(f"🟡 FIXTURE DEBUG: Temp dir created: {tmpdir}") - # Initialize git repo - print("🟡 FIXTURE DEBUG: Running git init...") - import subprocess - - subprocess.run(["git", "init"], cwd=tmpdir, check=True, capture_output=True) - print("🟡 FIXTURE DEBUG: Git init complete ✅") - yield tmpdir - print("🟡 FIXTURE DEBUG: Cleaning up temp dir...") - - -def test_db_only(db_debug): - """Test using only db fixture.""" - print("\n⭐ TEST: test_db_only started") - assert db_debug is not None - print("⭐ TEST: test_db_only passed!") - - -def test_temp_dir_only(temp_project_dir_debug): - """Test using only temp_project_dir fixture.""" - print("\n⭐ TEST: test_temp_dir_only started") - assert temp_project_dir_debug is not None - print("⭐ TEST: test_temp_dir_only passed!") - - -def test_both_fixtures(db_debug, temp_project_dir_debug): - """Test using both fixtures.""" - print("\n⭐ TEST: test_both_fixtures started") - project_id = db_debug.create_project("test-project", "Test Project project") - print(f"⭐ TEST: Created project {project_id}") - db_debug.update_project(project_id, {"workspace_path": temp_project_dir_debug}) - print("⭐ TEST: Updated project workspace_path") - print("⭐ TEST: test_both_fixtures passed!") diff --git a/tests/deployment/test_deployment_contract.py b/tests/deployment/test_deployment_contract.py deleted file mode 100644 index 88b702b7..00000000 --- a/tests/deployment/test_deployment_contract.py +++ /dev/null @@ -1,560 +0,0 @@ -""" -Deployment Contract Tests - -These tests validate the API contracts and environment configuration -that should be verified BEFORE deploying to staging/production. - -Created in response to cf-46 where production bugs were not caught by tests. -""" - -import sys -from pathlib import Path - -# Add project root to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from codeframe.persistence.database import Database -from codeframe.core.models import TaskStatus, Issue - - -class TestAPIContracts: - """Test that API endpoints return data matching frontend expectations.""" - - def test_projects_endpoint_contract(self): - """ - Validate complete /api/projects response schema. - - Frontend expects each project to have: - - id, name, status, phase, created_at (basic fields) - - progress (object with completed_tasks, total_tasks, percentage) - """ - # Given: A database with a project and tasks - db = Database(":memory:") - db.initialize() - - project_id = db.create_project("API Contract Test", "Api Contract Test project") - - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Test Issue", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.1", - parent_issue_number="1.1", - title="Task 1", - description="Completed task", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We call the API endpoint (via database layer) - projects = db.list_projects() - - # Then: Response must match frontend contract - assert len(projects) == 1 - project = projects[0] - - # Basic fields (frontend Dashboard.tsx:25-30) - required_basic_fields = ["id", "name", "status", "phase", "created_at"] - for field in required_basic_fields: - assert field in project, f"Missing required field: {field}" - - # Progress field (frontend Dashboard.tsx:194-218) - assert "progress" in project, "Missing 'progress' field - causes TypeError in Dashboard" - - progress = project["progress"] - assert isinstance(progress, dict), "'progress' must be a dict object" - - # Progress sub-fields (Dashboard.tsx expects these) - assert "completed_tasks" in progress - assert "total_tasks" in progress - assert "percentage" in progress - - # Type validation - assert isinstance(progress["completed_tasks"], int) - assert isinstance(progress["total_tasks"], int) - assert isinstance(progress["percentage"], float) - - # Value range validation - assert progress["completed_tasks"] >= 0 - assert progress["total_tasks"] >= 0 - assert 0.0 <= progress["percentage"] <= 100.0 - - def test_project_status_endpoint_contract(self): - """ - Validate /api/projects/{id}/status response schema. - - Frontend Dashboard.tsx uses THIS endpoint (not /api/projects). - This was the actual Bug 1 in cf-46! - """ - # Given: A database with a project and tasks - db = Database(":memory:") - db.initialize() - - project_id = db.create_project("Status Test", "Status Test project") - - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Test Issue", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.1", - parent_issue_number="1.1", - title="Completed Task", - description="Test", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We simulate the /status endpoint (get_project + calculate_progress) - project = db.get_project(project_id) - progress = db._calculate_project_progress(project_id) - - # Build response like server.py does - status_response = { - "project_id": project["id"], - "name": project["name"], - "status": project["status"], - "phase": project.get("phase", "discovery"), - "workflow_step": project.get("workflow_step", 1), - "progress": progress, - } - - # Then: Response must have progress field - assert "progress" in status_response, "Missing 'progress' - this was the cf-46 bug!" - - # Validate progress structure - assert "completed_tasks" in status_response["progress"] - assert "total_tasks" in status_response["progress"] - assert "percentage" in status_response["progress"] - - # Validate values - assert status_response["progress"]["completed_tasks"] == 1 - assert status_response["progress"]["total_tasks"] == 1 - assert status_response["progress"]["percentage"] == 100.0 - - def test_projects_endpoint_empty_database(self): - """Test /api/projects returns empty array when no projects exist.""" - # Given: An empty database - db = Database(":memory:") - db.initialize() - - # When: We fetch projects - projects = db.list_projects() - - # Then: Should return empty list (not None, not error) - assert projects == [] - assert isinstance(projects, list) - - def test_project_progress_calculation_correctness(self): - """ - Test that progress percentage is calculated correctly. - - This caught the cf-46 bug where progress was completely missing. - """ - # Given: Multiple projects with different completion rates - db = Database(":memory:") - db.initialize() - - test_cases = [ - ("0% complete", 0, 5), # 0 of 5 tasks - ("50% complete", 1, 2), # 1 of 2 tasks - ("100% complete", 3, 3), # 3 of 3 tasks - ("Empty project", 0, 0), # No tasks - ] - - for name, completed, total in test_cases: - project_id = db.create_project(name, f"{name} project") - - if total > 0: - issue = Issue( - project_id=project_id, - issue_number="1.1", - title=f"Issue for {name}", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - for i in range(total): - status = TaskStatus.COMPLETED if i < completed else TaskStatus.PENDING - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"1.1.{i+1}", - parent_issue_number="1.1", - title=f"Task {i+1}", - description=f"Task {i+1}", - status=status, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch all projects - projects = db.list_projects() - - # Then: Each project should have correct progress - expected_percentages = { - "0% complete": 0.0, - "50% complete": 50.0, - "100% complete": 100.0, - "Empty project": 0.0, - } - - for project in projects: - expected = expected_percentages[project["name"]] - actual = project["progress"]["percentage"] - assert actual == expected, f"{project['name']}: expected {expected}%, got {actual}%" - - -class TestEnvironmentConfiguration: - """Test that environment variables are properly configured.""" - - def test_cors_allowed_origins_configurable(self): - """ - Test that CORS origins can be configured via environment variable. - - This prevents hardcoded CORS origins (cf-46 issue). - """ - # This test validates that the server.py code reads CORS_ALLOWED_ORIGINS - # We can't easily test the FastAPI middleware without starting the server, - # but we can validate the configuration pattern. - - # The pattern should be: - # 1. Read CORS_ALLOWED_ORIGINS from env - # 2. Parse comma-separated list - # 3. Use in CORSMiddleware - - # Given: Various CORS configuration strings - test_cases = [ - ("", []), # Empty should use defaults - ("http://localhost:3000", ["http://localhost:3000"]), - ( - "http://localhost:3000,http://example.com", - ["http://localhost:3000", "http://example.com"], - ), - ( - " http://a.com , http://b.com ", - ["http://a.com", "http://b.com"], - ), # Whitespace handling - ] - - for env_value, expected_origins in test_cases: - # When: We parse the environment variable - if env_value: - origins = [origin.strip() for origin in env_value.split(",") if origin.strip()] - else: - origins = [] - - # Then: Should parse correctly - assert origins == expected_origins, f"Failed to parse: {env_value!r}" - - def test_next_public_api_url_required(self): - """ - Test that NEXT_PUBLIC_API_URL is set for deployment. - - Missing this causes frontend to try localhost:8080 instead of actual server. - """ - # In deployment scenarios, this variable MUST be set - # This is a reminder test that validates the requirement - - required_env_vars = [ - "NEXT_PUBLIC_API_URL", - "NEXT_PUBLIC_WS_URL", - ] - - # Note: In actual deployment, these are read from .env.staging - # This test documents the requirement - for var in required_env_vars: - # Test passes - just documenting the requirement - assert var in ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_WS_URL"] - - def test_backend_port_configuration(self): - """ - Test that backend port can be configured via environment. - - Validates BACKEND_PORT is used in next.config.js - """ - # Given: Different port configurations - test_ports = ["8080", "14200", "3001"] - - for port in test_ports: - # When: BACKEND_PORT environment variable is set - # Then: Should be used for backend URL - # Note: This is validated by next.config.js reading process.env.BACKEND_PORT - assert port.isdigit() - assert 1 <= int(port) <= 65535 - - -class TestDataIntegrity: - """Test data integrity constraints.""" - - def test_task_status_values(self): - """ - Test that task status values match what frontend expects. - - Frontend Dashboard checks for status === 'completed' exactly. - """ - # Valid task statuses from TaskStatus enum - - # Given: A database with tasks in various statuses - db = Database(":memory:") - db.initialize() - - project_id = db.create_project("Status Test", "Status Test project") - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Test", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - # Create a task with 'completed' status - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.1.1", - parent_issue_number="1.1", - title="Completed Task", - description="Test", - status=TaskStatus.COMPLETED, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch projects - projects = db.list_projects() - - # Then: Completed task should be counted - assert projects[0]["progress"]["completed_tasks"] == 1 - - def test_progress_calculation_ignores_non_completed_statuses(self): - """ - Test that only 'completed' status counts as completed. - - Statuses like 'in_progress', 'blocked' should NOT count as completed. - """ - # Given: A project with tasks in various non-completed statuses - db = Database(":memory:") - db.initialize() - - project_id = db.create_project("Status Test", "Status Test project") - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Test", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - # Create tasks with non-completed statuses - for i, status in enumerate( - [TaskStatus.PENDING, TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED] - ): - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"1.1.{i+1}", - parent_issue_number="1.1", - title=f"Task {i+1}", - description=f"Task in {status.value} status", - status=status, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch projects - projects = db.list_projects() - - # Then: No tasks should be counted as completed - assert projects[0]["progress"]["completed_tasks"] == 0 - assert projects[0]["progress"]["total_tasks"] == 3 - assert projects[0]["progress"]["percentage"] == 0.0 - - -class TestEdgeCases: - """Test edge cases that might break in production.""" - - def test_project_with_null_fields(self): - """Test that projects with NULL optional fields don't break API.""" - # Given: A project with minimal fields (nulls for optionals) - db = Database(":memory:") - db.initialize() - - db.create_project("Minimal Project", "Minimal Project project") - - # When: We fetch projects - projects = db.list_projects() - - # Then: Should handle nulls gracefully - project = projects[0] - assert project["workspace_path"] == "" # Default empty workspace - assert project["config"] is None # Optional field - assert "progress" in project # Required field - assert project["progress"]["total_tasks"] == 0 - - def test_large_project_performance(self): - """ - Test that progress calculation is efficient for projects with many tasks. - - Ensures we don't have N+1 query problems. - """ - # Given: A project with many tasks (100+) - db = Database(":memory:") - db.initialize() - - project_id = db.create_project("Large Project", "Large Project project") - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Large Issue", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - issue_id = db.create_issue(issue) - - # Create 100 tasks (50 completed, 50 pending) - for i in range(100): - status = TaskStatus.COMPLETED if i < 50 else TaskStatus.PENDING - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"1.1.{i+1}", - parent_issue_number="1.1", - title=f"Task {i+1}", - description=f"Task {i+1}", - status=status, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch projects - import time - - start = time.time() - projects = db.list_projects() - elapsed = time.time() - start - - # Then: Should complete quickly (< 100ms for 100 tasks) - assert elapsed < 0.1, f"Progress calculation too slow: {elapsed:.3f}s" - - # And: Should have correct counts - assert projects[0]["progress"]["total_tasks"] == 100 - assert projects[0]["progress"]["completed_tasks"] == 50 - assert projects[0]["progress"]["percentage"] == 50.0 - - def test_multiple_projects_independent_progress(self): - """ - Test that progress is calculated independently for each project. - - Ensures no cross-project contamination in progress calculation. - """ - # Given: Two projects with different task counts - db = Database(":memory:") - db.initialize() - - # Project 1: 75% complete - p1_id = db.create_project("Project 1", "Project 1 project") - i1 = db.create_issue( - Issue( - project_id=p1_id, - issue_number="1.1", - title="I1", - description="", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - ) - for i in range(4): - db.create_task_with_issue( - project_id=p1_id, - issue_id=i1, - task_number=f"1.1.{i+1}", - parent_issue_number="1.1", - title=f"T{i+1}", - description="", - status=TaskStatus.COMPLETED if i < 3 else TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # Project 2: 25% complete - p2_id = db.create_project("Project 2", "Project 2 project") - i2 = db.create_issue( - Issue( - project_id=p2_id, - issue_number="1.1", - title="I1", - description="", - status=TaskStatus.IN_PROGRESS, - priority=2, - workflow_step=1, - ) - ) - for i in range(4): - db.create_task_with_issue( - project_id=p2_id, - issue_id=i2, - task_number=f"1.1.{i+1}", - parent_issue_number="1.1", - title=f"T{i+1}", - description="", - status=TaskStatus.COMPLETED if i < 1 else TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # When: We fetch all projects - projects = db.list_projects() - - # Then: Each project should have independent progress - p1 = next(p for p in projects if p["name"] == "Project 1") - p2 = next(p for p in projects if p["name"] == "Project 2") - - assert p1["progress"]["completed_tasks"] == 3 - assert p1["progress"]["total_tasks"] == 4 - assert p1["progress"]["percentage"] == 75.0 - - assert p2["progress"]["completed_tasks"] == 1 - assert p2["progress"]["total_tasks"] == 4 - assert p2["progress"]["percentage"] == 25.0 diff --git a/tests/enforcement/test_adaptive_test_runner.py b/tests/enforcement/test_adaptive_test_runner.py deleted file mode 100644 index 1561e93c..00000000 --- a/tests/enforcement/test_adaptive_test_runner.py +++ /dev/null @@ -1,279 +0,0 @@ -""" -Tests for AdaptiveTestRunner - multi-language test execution system. -""" - -import json -from unittest.mock import Mock, patch -import subprocess - -import pytest - -from codeframe.enforcement import AdaptiveTestRunner - - -class TestAdaptiveTestRunner: - """Test adaptive test running for various languages.""" - - @pytest.mark.asyncio - async def test_detects_language_on_first_run(self, tmp_path): - """Test that runner auto-detects language on first run""" - # Create a Python project - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - # Mock subprocess to avoid actually running tests - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=0, stdout="5 passed in 1.23s", stderr="") - - await runner.run_tests() - - assert runner.language_info is not None - assert runner.language_info.language == "python" - - @pytest.mark.asyncio - async def test_parses_pytest_output(self, tmp_path): - """Test parsing pytest output format""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=1, # Non-zero for failures - stdout="===== 8 passed, 2 failed in 2.34s =====", - stderr="", - ) - - result = await runner.run_tests() - - assert result.success is False # Has failures - assert result.total_tests == 10 - assert result.passed_tests == 8 - assert result.failed_tests == 2 - - @pytest.mark.asyncio - async def test_parses_jest_output(self, tmp_path): - """Test parsing Jest output format""" - package_json = {"devDependencies": {"jest": "^29.0.0"}} - (tmp_path / "package.json").write_text(json.dumps(package_json)) - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, stdout="Tests: 2 failed, 8 passed, 10 total", stderr="" - ) - - result = await runner.run_tests() - - assert result.total_tests == 10 - assert result.passed_tests == 8 - assert result.failed_tests == 2 - - @pytest.mark.asyncio - async def test_parses_go_test_output(self, tmp_path): - """Test parsing Go test output""" - (tmp_path / "go.mod").write_text("module example.com/myapp\n\ngo 1.21") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, - stdout=""" -PASS: TestUserAuth (0.01s) -PASS: TestDataValidation (0.02s) -FAIL: TestEdgeCase (0.01s) -PASS - """, - stderr="", - ) - - result = await runner.run_tests() - - assert result.passed_tests >= 2 - assert result.failed_tests >= 1 - - @pytest.mark.asyncio - async def test_parses_rust_cargo_output(self, tmp_path): - """Test parsing Rust cargo test output""" - (tmp_path / "Cargo.toml").write_text('[package]\nname = "myapp"') - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, - stdout="test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured", - stderr="", - ) - - result = await runner.run_tests() - - assert result.success is True - assert result.passed_tests == 10 - assert result.failed_tests == 0 - - @pytest.mark.asyncio - async def test_extracts_coverage_from_pytest(self, tmp_path): - """Test extracting coverage from pytest output""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, - stdout=""" -===== 10 passed in 1.23s ===== -TOTAL 87% - """, - stderr="", - ) - - result = await runner.run_tests(with_coverage=True) - - assert result.coverage == 87.0 - - @pytest.mark.asyncio - async def test_extracts_coverage_from_jest(self, tmp_path): - """Test extracting coverage from Jest output""" - package_json = {"devDependencies": {"jest": "^29.0.0"}} - (tmp_path / "package.json").write_text(json.dumps(package_json)) - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, - stdout=""" -Tests: 10 passed, 10 total -All files | 92.5 | 91.2 | 95.0 | 92.5 | - """, - stderr="", - ) - - result = await runner.run_tests(with_coverage=True) - - assert result.coverage == 92.5 - - @pytest.mark.asyncio - async def test_handles_test_failures(self, tmp_path): - """Test handling non-zero exit codes""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=1, stdout="5 passed, 5 failed in 2.34s", stderr="" # Failure exit code - ) - - result = await runner.run_tests() - - assert result.success is False - assert result.failed_tests == 5 - - @pytest.mark.asyncio - async def test_detects_skipped_tests(self, tmp_path): - """Test detection of skipped tests""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, stdout="8 passed, 2 skipped in 1.23s", stderr="" - ) - - result = await runner.run_tests() - - assert result.skipped_tests == 2 - - @pytest.mark.asyncio - async def test_calculates_pass_rate(self, tmp_path): - """Test pass rate calculation""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, stdout="8 passed, 2 failed in 1.23s", stderr="" - ) - - result = await runner.run_tests() - - assert result.pass_rate == 80.0 # 8/10 = 80% - - @pytest.mark.asyncio - async def test_handles_subprocess_errors(self, tmp_path): - """Test handling subprocess errors gracefully""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired("pytest", 30) - - # Should raise TimeoutExpired since no error handling in implementation - with pytest.raises(subprocess.TimeoutExpired): - await runner.run_tests() - - -class TestAdaptiveTestRunnerOutputParsing: - """Test output parsing for different frameworks.""" - - @pytest.mark.asyncio - async def test_parses_maven_output(self, tmp_path): - """Test parsing Maven test output""" - (tmp_path / "pom.xml").write_text("") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, stdout="Tests run: 15, Failures: 2, Errors: 0, Skipped: 1", stderr="" - ) - - result = await runner.run_tests() - - assert result.total_tests == 15 - assert result.failed_tests == 2 - assert result.skipped_tests == 1 - - @pytest.mark.asyncio - async def test_handles_no_tests_found(self, tmp_path): - """Test handling when no tests are found""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=5, # pytest exit code for no tests - stdout="no tests ran in 0.01s", - stderr="", - ) - - result = await runner.run_tests() - - assert result.total_tests == 0 - - @pytest.mark.asyncio - async def test_combines_stdout_and_stderr(self, tmp_path): - """Test that output includes both stdout and stderr""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - runner = AdaptiveTestRunner(str(tmp_path)) - - with patch("codeframe.enforcement.adaptive_test_runner.subprocess.run") as mock_run: - mock_run.return_value = Mock( - returncode=0, stdout="Tests passed", stderr="WARNING: Deprecation" - ) - - result = await runner.run_tests() - - assert "Tests passed" in result.output - assert "WARNING" in result.output or "Deprecation" in result.output diff --git a/tests/enforcement/test_evidence_verifier.py b/tests/enforcement/test_evidence_verifier.py deleted file mode 100644 index 118c7285..00000000 --- a/tests/enforcement/test_evidence_verifier.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -Tests for EvidenceVerifier - validates agent claims. -""" - -from codeframe.enforcement import EvidenceVerifier, TestResult, SkipViolation - - -class TestEvidenceVerifier: - """Test evidence verification.""" - - def test_verifies_passing_tests_with_coverage(self): - """Test verification of passing evidence""" - verifier = EvidenceVerifier(min_coverage=85.0) - - test_result = TestResult( - success=True, - total_tests=10, - passed_tests=10, - failed_tests=0, - skipped_tests=0, - pass_rate=100.0, - coverage=90.0, - output="All tests passed", - duration=1.23, - ) - - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=[], - language="python", - agent_id="worker-001", - task_description="Implement user auth", - ) - - assert verifier.verify(evidence) is True - assert evidence.verified is True - - def test_rejects_failing_tests(self): - """Test rejection when tests fail""" - verifier = EvidenceVerifier() - - test_result = TestResult( - success=False, - total_tests=10, - passed_tests=8, - failed_tests=2, - skipped_tests=0, - pass_rate=80.0, - coverage=85.0, - output="2 tests failed", - duration=1.23, - ) - - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=[], - language="python", - agent_id="worker-001", - task_description="Test task", - ) - - assert verifier.verify(evidence) is False - assert any("failed" in error.lower() for error in evidence.verification_errors) - - def test_rejects_low_coverage(self): - """Test rejection when coverage too low""" - verifier = EvidenceVerifier(min_coverage=85.0) - - test_result = TestResult( - success=True, - total_tests=10, - passed_tests=10, - failed_tests=0, - skipped_tests=0, - pass_rate=100.0, - coverage=70.0, # Below threshold - output="All tests passed", - duration=1.23, - ) - - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=[], - language="python", - agent_id="worker-001", - task_description="Test task", - ) - - assert verifier.verify(evidence) is False - assert any("coverage" in error.lower() for error in evidence.verification_errors) - - def test_rejects_skip_violations(self): - """Test rejection when skip violations found""" - verifier = EvidenceVerifier(allow_skipped_tests=False) - - test_result = TestResult( - success=True, - total_tests=10, - passed_tests=10, - failed_tests=0, - skipped_tests=0, - pass_rate=100.0, - coverage=90.0, - output="All tests passed", - duration=1.23, - ) - - skip_violations = [ - SkipViolation( - file="test_user.py", - line=10, - pattern="@skip", - context="test_something", - reason=None, - severity="error", - ) - ] - - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=skip_violations, - language="python", - agent_id="worker-001", - task_description="Test task", - ) - - assert verifier.verify(evidence) is False - assert any("skip" in error.lower() for error in evidence.verification_errors) - - def test_generates_report(self): - """Test report generation""" - verifier = EvidenceVerifier() - - test_result = TestResult( - success=True, - total_tests=10, - passed_tests=10, - failed_tests=0, - skipped_tests=0, - pass_rate=100.0, - coverage=90.0, - output="All tests passed", - duration=1.23, - ) - - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=[], - language="python", - agent_id="worker-001", - task_description="Implement feature X", - ) - - verifier.verify(evidence) - report = verifier.generate_report(evidence) - - assert "EVIDENCE VERIFICATION REPORT" in report - assert "worker-001" in report - assert "Implement feature X" in report - assert "PASSED" in report - - def test_works_with_any_language(self): - """Test language-agnostic verification""" - verifier = EvidenceVerifier(min_coverage=80.0) - - # Go project - test_result = TestResult( - success=True, - total_tests=20, - passed_tests=20, - failed_tests=0, - skipped_tests=0, - pass_rate=100.0, - coverage=85.0, - output="ok \tgithub.com/example/pkg\t2.500s", # More realistic Go output - duration=2.5, - ) - - evidence = verifier.collect_evidence( - test_result=test_result, - skip_violations=[], - language="go", - agent_id="worker-002", - task_description="Add API endpoint", - framework="go test", - ) - - assert verifier.verify(evidence) is True - assert evidence.quality_metrics.language == "go" - assert evidence.quality_metrics.framework == "go test" diff --git a/tests/enforcement/test_language_detector.py b/tests/enforcement/test_language_detector.py deleted file mode 100644 index d9b969d4..00000000 --- a/tests/enforcement/test_language_detector.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Tests for LanguageDetector - multi-language detection system. -""" - -import json - - -from codeframe.enforcement import LanguageDetector - - -class TestLanguageDetector: - """Test language detection for various project types.""" - - def test_detects_python_with_pyproject_toml(self, tmp_path): - """Test detection of Python project via pyproject.toml""" - # Create a minimal Python project - (tmp_path / "pyproject.toml").write_text( - """ -[tool.pytest.ini_options] -testpaths = ["tests"] -""" - ) - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "python" - assert "pytest" in info.test_command or "unittest" in info.test_command - assert info.confidence > 0.5 - - def test_detects_javascript_with_package_json(self, tmp_path): - """Test detection of JavaScript project via package.json""" - package_json = {"name": "test-project", "devDependencies": {"jest": "^29.0.0"}} - (tmp_path / "package.json").write_text(json.dumps(package_json)) - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "javascript" - assert info.framework == "jest" - assert "it.skip" in info.skip_patterns - - def test_detects_typescript_with_tsconfig(self, tmp_path): - """Test detection of TypeScript project""" - (tmp_path / "tsconfig.json").write_text('{"compilerOptions": {}}') - (tmp_path / "package.json").write_text('{"devDependencies": {"vitest": "^0.34.0"}}') - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "typescript" - assert "*.test.ts" in info.test_patterns - - def test_detects_go_with_go_mod(self, tmp_path): - """Test detection of Go project""" - (tmp_path / "go.mod").write_text("module example.com/myapp\n\ngo 1.21") - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "go" - assert info.framework == "go test" - assert "go test" in info.test_command - assert "t.Skip(" in info.skip_patterns - - def test_detects_rust_with_cargo_toml(self, tmp_path): - """Test detection of Rust project""" - (tmp_path / "Cargo.toml").write_text( - """ -[package] -name = "myapp" -version = "0.1.0" -""" - ) - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "rust" - assert info.framework == "cargo test" - assert "#[ignore]" in info.skip_patterns - - def test_detects_java_maven_with_pom_xml(self, tmp_path): - """Test detection of Java Maven project""" - (tmp_path / "pom.xml").write_text("") - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "java" - assert info.framework == "maven" - assert "mvn test" in info.test_command - assert "@Ignore" in info.skip_patterns - - def test_detects_java_gradle_with_build_gradle(self, tmp_path): - """Test detection of Java Gradle project""" - (tmp_path / "build.gradle").write_text("plugins { id 'java' }") - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "java" - assert info.framework == "gradle" - assert "@Disabled" in info.skip_patterns - - def test_detects_ruby_with_gemfile(self, tmp_path): - """Test detection of Ruby project with RSpec""" - (tmp_path / "Gemfile").write_text("gem 'rspec'") - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "ruby" - assert info.framework == "rspec" - assert "skip" in info.skip_patterns - - def test_detects_csharp_with_csproj(self, tmp_path): - """Test detection of C# .NET project""" - (tmp_path / "MyApp.csproj").write_text('') - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "csharp" - assert "dotnet test" in info.test_command - assert "[Ignore]" in info.skip_patterns - - def test_returns_unknown_for_unrecognized_project(self, tmp_path): - """Test fallback to unknown for unrecognized projects""" - # Empty directory - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.language == "unknown" - assert info.confidence == 0.0 - - def test_python_with_pytest_in_pyproject(self, tmp_path): - """Test Python detection prefers pytest when configured""" - (tmp_path / "pyproject.toml").write_text( - """ -[tool.pytest.ini_options] -testpaths = ["tests"] - -[project.optional-dependencies] -dev = ["pytest>=8.0.0"] -""" - ) - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.framework == "pytest" - assert "pytest" in info.test_command - - -class TestLanguageDetectorConfidence: - """Test confidence scoring system.""" - - def test_high_confidence_with_multiple_markers(self, tmp_path): - """Test high confidence when multiple markers present""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - (tmp_path / "pytest.ini").write_text("[pytest]") - (tmp_path / "setup.py").write_text("from setuptools import setup") - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - assert info.confidence > 0.8 - - def test_lower_confidence_with_few_markers(self, tmp_path): - """Test lower confidence with minimal markers""" - (tmp_path / "requirements.txt").write_text("requests==2.0.0") - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - # Should still detect Python but with lower confidence - assert 0.5 < info.confidence < 0.9 - - -class TestLanguageDetectorSkipPatterns: - """Test skip pattern detection for each language.""" - - def test_python_skip_patterns_comprehensive(self, tmp_path): - """Test all Python skip patterns are included""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - expected_patterns = [ - "@skip", - "@skipif", - "@pytest.mark.skip", - "@pytest.mark.skipif", - "@unittest.skip", - ] - - for pattern in expected_patterns: - assert pattern in info.skip_patterns, f"Missing skip pattern: {pattern}" - - def test_javascript_skip_patterns_comprehensive(self, tmp_path): - """Test all JavaScript skip patterns are included""" - (tmp_path / "package.json").write_text('{"devDependencies": {"jest": "^29.0.0"}}') - - detector = LanguageDetector(str(tmp_path)) - info = detector.detect() - - expected_patterns = ["it.skip", "test.skip", "describe.skip", "xit", "xtest", "xdescribe"] - - for pattern in expected_patterns: - assert pattern in info.skip_patterns, f"Missing skip pattern: {pattern}" diff --git a/tests/enforcement/test_quality_ratchet.py b/tests/enforcement/test_quality_ratchet.py deleted file mode 100644 index ac9bdfbb..00000000 --- a/tests/enforcement/test_quality_ratchet.py +++ /dev/null @@ -1,316 +0,0 @@ -""" -Unit tests for quality ratchet system. - -These tests verify that the quality ratchet correctly tracks metrics, -detects degradation, and provides useful statistics. - -Test Coverage: -- T032: record command creates history entry -- T033: check command detects degradation >10% -- T034: stats command formats Rich Table output correctly -- T035: reset command clears history -- T036: moving average calculation (last 3 checkpoints) -- T037: peak quality detection algorithm -- T038: JSON persistence to .claude/quality_history.json -- T039: handles missing history file gracefully -""" - -import importlib.util -import json -from pathlib import Path - - -# Import the quality ratchet module -scripts_dir = Path(__file__).parent.parent.parent / "scripts" -script_path = scripts_dir / "quality-ratchet.py" - -spec = importlib.util.spec_from_file_location("quality_ratchet", script_path) -quality_ratchet = importlib.util.module_from_spec(spec) -spec.loader.exec_module(quality_ratchet) - -load_history = quality_ratchet.load_history -save_history = quality_ratchet.save_history -detect_degradation = quality_ratchet.detect_degradation -calculate_moving_average = quality_ratchet.calculate_moving_average -find_peak_quality = quality_ratchet.find_peak_quality - - -class TestQualityRatchetRecord: - """Test the record command functionality.""" - - def test_record_creates_history_entry(self, tmp_path): - """T032: Test record command creates history entry""" - history_file = tmp_path / "quality_history.json" - - # Start with empty history - history = [] - save_history(history, str(history_file)) - - # Add a record - entry = { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 95.5, - "coverage_percentage": 87.3, - } - history.append(entry) - save_history(history, str(history_file)) - - # Verify it was saved - loaded = load_history(str(history_file)) - assert len(loaded) == 1 - assert loaded[0]["response_count"] == 5 - assert loaded[0]["test_pass_rate"] == 95.5 - assert loaded[0]["coverage_percentage"] == 87.3 - - -class TestQualityRatchetCheck: - """Test the check command for degradation detection.""" - - def test_check_detects_coverage_degradation(self): - """T033: Test check command detects coverage degradation >10%""" - history = [ - { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 100.0, - "coverage_percentage": 90.0, - }, - { - "timestamp": "2025-11-15T10:30:00", - "response_count": 10, - "test_pass_rate": 100.0, - "coverage_percentage": 75.0, # 15% drop - }, - ] - - degradation = detect_degradation(history) - assert degradation is not None - assert "coverage" in degradation or degradation.get("has_degradation") is True - - def test_check_detects_pass_rate_degradation(self): - """Test check command detects pass rate degradation >10%""" - history = [ - { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 100.0, - "coverage_percentage": 90.0, - }, - { - "timestamp": "2025-11-15T10:30:00", - "response_count": 10, - "test_pass_rate": 85.0, # 15% drop - "coverage_percentage": 90.0, - }, - ] - - degradation = detect_degradation(history) - assert degradation is not None - assert "pass_rate" in degradation or degradation.get("has_degradation") is True - - def test_check_passes_with_no_degradation(self): - """Test check command passes when quality is stable""" - history = [ - { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 100.0, - "coverage_percentage": 90.0, - }, - { - "timestamp": "2025-11-15T10:30:00", - "response_count": 10, - "test_pass_rate": 98.0, - "coverage_percentage": 89.0, - }, - ] - - degradation = detect_degradation(history) - # Should either be None or indicate no degradation - if degradation is not None: - assert degradation.get("has_degradation") is False - - -class TestQualityRatchetStats: - """Test the stats command output formatting.""" - - def test_stats_command_formats_output(self): - """T034: Test stats command formats Rich Table output correctly""" - # This test verifies the data structure used for stats display - history = [ - { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 95.5, - "coverage_percentage": 87.3, - }, - { - "timestamp": "2025-11-15T10:30:00", - "response_count": 10, - "test_pass_rate": 97.2, - "coverage_percentage": 89.1, - }, - ] - - # Calculate stats - current = history[-1] - peak = find_peak_quality(history) - avg = calculate_moving_average(history, window=3) - - assert current["test_pass_rate"] == 97.2 - assert peak["test_pass_rate"] >= 95.5 - assert avg["test_pass_rate"] > 0 - - -class TestQualityRatchetReset: - """Test the reset command functionality.""" - - def test_reset_clears_history(self, tmp_path): - """T035: Test reset command clears history""" - history_file = tmp_path / "quality_history.json" - - # Create history with some entries - history = [ - { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 95.5, - "coverage_percentage": 87.3, - } - ] - save_history(history, str(history_file)) - - # Reset (clear history) - save_history([], str(history_file)) - - # Verify it's empty - loaded = load_history(str(history_file)) - assert len(loaded) == 0 - - -class TestQualityRatchetCalculations: - """Test calculation functions.""" - - def test_moving_average_calculation(self): - """T036: Test moving average calculation (last 3 checkpoints)""" - history = [ - {"test_pass_rate": 90.0, "coverage_percentage": 85.0}, - {"test_pass_rate": 95.0, "coverage_percentage": 87.0}, - {"test_pass_rate": 92.0, "coverage_percentage": 86.0}, - {"test_pass_rate": 88.0, "coverage_percentage": 84.0}, - ] - - avg = calculate_moving_average(history, window=3) - - # Average of last 3: (95 + 92 + 88) / 3 = 91.67 - assert 91.0 <= avg["test_pass_rate"] <= 92.0 - # Average of last 3: (87 + 86 + 84) / 3 = 85.67 - assert 85.0 <= avg["coverage_percentage"] <= 86.0 - - def test_moving_average_with_fewer_entries(self): - """Test moving average with fewer entries than window size""" - history = [ - {"test_pass_rate": 90.0, "coverage_percentage": 85.0}, - ] - - avg = calculate_moving_average(history, window=3) - assert avg["test_pass_rate"] == 90.0 - assert avg["coverage_percentage"] == 85.0 - - def test_peak_quality_detection(self): - """T037: Test peak quality detection algorithm""" - history = [ - {"test_pass_rate": 90.0, "coverage_percentage": 85.0}, - {"test_pass_rate": 100.0, "coverage_percentage": 92.0}, # Peak - {"test_pass_rate": 95.0, "coverage_percentage": 88.0}, - ] - - peak = find_peak_quality(history) - assert peak["test_pass_rate"] == 100.0 - assert peak["coverage_percentage"] == 92.0 - - -class TestQualityRatchetPersistence: - """Test JSON persistence functionality.""" - - def test_json_persistence(self, tmp_path): - """T038: Test JSON persistence to .claude/quality_history.json""" - history_file = tmp_path / "quality_history.json" - - history = [ - { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 95.5, - "coverage_percentage": 87.3, - }, - { - "timestamp": "2025-11-15T10:30:00", - "response_count": 10, - "test_pass_rate": 97.2, - "coverage_percentage": 89.1, - }, - ] - - save_history(history, str(history_file)) - - # Read directly from file - with open(history_file, "r") as f: - data = json.load(f) - - assert len(data) == 2 - assert data[0]["response_count"] == 5 - assert data[1]["response_count"] == 10 - - def test_handles_missing_history_file(self, tmp_path): - """T039: Test handles missing history file gracefully""" - history_file = tmp_path / "nonexistent.json" - - # Should return empty list, not raise error - history = load_history(str(history_file)) - assert history == [] - - def test_handles_corrupted_history_file(self, tmp_path): - """Test handles corrupted JSON gracefully""" - history_file = tmp_path / "corrupted.json" - - # Write invalid JSON - with open(history_file, "w") as f: - f.write("{ invalid json") - - # Should return empty list or handle gracefully - history = load_history(str(history_file)) - assert isinstance(history, list) - - -class TestQualityRatchetEdgeCases: - """Test edge cases and error handling.""" - - def test_empty_history(self): - """Test handling of empty history""" - history = [] - - # Should not crash - peak = find_peak_quality(history) - assert peak is None or isinstance(peak, dict) - - avg = calculate_moving_average(history) - assert avg is None or isinstance(avg, dict) - - def test_single_entry_history(self): - """Test handling of single entry""" - history = [ - { - "timestamp": "2025-11-15T10:00:00", - "response_count": 5, - "test_pass_rate": 95.5, - "coverage_percentage": 87.3, - } - ] - - peak = find_peak_quality(history) - assert peak["test_pass_rate"] == 95.5 - - avg = calculate_moving_average(history) - assert avg["test_pass_rate"] == 95.5 diff --git a/tests/enforcement/test_quality_tracker_enforcement.py b/tests/enforcement/test_quality_tracker_enforcement.py deleted file mode 100644 index 25d31b3d..00000000 --- a/tests/enforcement/test_quality_tracker_enforcement.py +++ /dev/null @@ -1,152 +0,0 @@ -""" -Tests for QualityTracker (enforcement module) - generic quality tracking. -""" - -from datetime import datetime -from codeframe.enforcement import QualityTracker, QualityMetrics - - -class TestQualityTracker: - """Test quality tracking across languages.""" - - def test_records_quality_metrics(self, tmp_path): - """Test recording quality checkpoints""" - tracker = QualityTracker(str(tmp_path)) - - metrics = QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=5, - test_pass_rate=95.0, - coverage_percentage=87.5, - total_tests=100, - passed_tests=95, - failed_tests=5, - language="python", - framework="pytest", - ) - - tracker.record(metrics) - - history = tracker.load_history() - assert len(history) == 1 - assert history[0]["test_pass_rate"] == 95.0 - - def test_detects_degradation(self, tmp_path): - """Test degradation detection""" - tracker = QualityTracker(str(tmp_path)) - - # Record peak quality - tracker.record( - QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=1, - test_pass_rate=100.0, - coverage_percentage=90.0, - total_tests=100, - passed_tests=100, - failed_tests=0, - language="python", - framework="pytest", - ) - ) - - # Record degraded quality - tracker.record( - QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=2, - test_pass_rate=85.0, # 15% drop - coverage_percentage=75.0, # 15% drop - total_tests=100, - passed_tests=85, - failed_tests=15, - language="python", - framework="pytest", - ) - ) - - degradation = tracker.check_degradation(threshold_percent=10.0) - assert degradation["has_degradation"] is True - - def test_works_with_any_language(self, tmp_path): - """Test language-agnostic tracking""" - tracker = QualityTracker(str(tmp_path)) - - # Track Go project - tracker.record( - QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=1, - test_pass_rate=100.0, - coverage_percentage=85.0, - total_tests=50, - passed_tests=50, - failed_tests=0, - language="go", - framework="go test", - ) - ) - - # Track JavaScript project - tracker.record( - QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=2, - test_pass_rate=95.0, - coverage_percentage=88.0, - total_tests=75, - passed_tests=71, - failed_tests=4, - language="javascript", - framework="jest", - ) - ) - - history = tracker.load_history() - assert len(history) == 2 - assert history[0]["language"] == "go" - assert history[1]["language"] == "javascript" - - def test_get_stats(self, tmp_path): - """Test statistics calculation""" - tracker = QualityTracker(str(tmp_path)) - - tracker.record( - QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=1, - test_pass_rate=100.0, - coverage_percentage=90.0, - total_tests=100, - passed_tests=100, - failed_tests=0, - language="python", - ) - ) - - stats = tracker.get_stats() - assert stats["has_data"] is True - assert stats["total_checkpoints"] == 1 - assert stats["current"]["test_pass_rate"] == 100.0 - - def test_reset_clears_history(self, tmp_path): - """Test reset functionality""" - tracker = QualityTracker(str(tmp_path)) - - tracker.record( - QualityMetrics( - timestamp=datetime.now().isoformat(), - response_count=1, - test_pass_rate=100.0, - coverage_percentage=90.0, - total_tests=100, - passed_tests=100, - failed_tests=0, - language="python", - ) - ) - - tracker.reset() - - history = tracker.load_history() - assert len(history) == 0 diff --git a/tests/enforcement/test_skip_detector.py b/tests/enforcement/test_skip_detector.py deleted file mode 100644 index 07449bef..00000000 --- a/tests/enforcement/test_skip_detector.py +++ /dev/null @@ -1,269 +0,0 @@ -""" -Unit tests for skip decorator detection tool. - -These tests verify that the skip detector correctly identifies and reports -skip decorators in test files. - -Test Coverage: -- T012: @skip detection -- T013: @skipif detection -- T014: @pytest.mark.skip detection -- T015: Skip with no reason (violation) -- T016: Skip with strong justification (allowed if policy changes) -- T017: Nested decorators handling -- T018: Non-test file handling (no false positives) -- T019: Performance <100ms on large files (200ms in CI) -""" - -import ast -import os -import tempfile -import time -from pathlib import Path - - -# Import the skip detector module using importlib (due to hyphen in filename) -import importlib.util - -scripts_dir = Path(__file__).parent.parent.parent / "scripts" -script_path = scripts_dir / "detect-skip-abuse.py" - -spec = importlib.util.spec_from_file_location("detect_skip_abuse", script_path) -detect_skip_abuse = importlib.util.module_from_spec(spec) -spec.loader.exec_module(detect_skip_abuse) - -SkipDetectorVisitor = detect_skip_abuse.SkipDetectorVisitor -check_file = detect_skip_abuse.check_file -is_test_file = detect_skip_abuse.is_test_file -format_violation = detect_skip_abuse.format_violation - - -class TestSkipDetection: - """Test basic skip decorator detection.""" - - def test_detects_simple_skip_decorator(self): - """T012: Test @skip detection""" - code = """ -import pytest - -@skip -def test_example(): - pass -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("test.py") - visitor.visit(tree) - - assert len(visitor.violations) == 1 - assert "@skip" in visitor.violations[0]["decorator"] - - def test_detects_skipif_decorator(self): - """T013: Test @skipif detection""" - code = """ -import pytest - -@skipif(sys.version_info < (3, 10)) -def test_example(): - pass -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("test.py") - visitor.visit(tree) - - assert len(visitor.violations) == 1 - assert "@skipif" in visitor.violations[0]["decorator"] - - def test_detects_pytest_mark_skip(self): - """T014: Test @pytest.mark.skip detection""" - code = """ -import pytest - -@pytest.mark.skip -def test_example(): - pass -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("test.py") - visitor.visit(tree) - - assert len(visitor.violations) == 1 - assert "pytest.mark.skip" in visitor.violations[0]["decorator"] - - def test_detects_skip_with_no_reason(self): - """T015: Test skip with no reason (violation)""" - code = """ -import pytest - -@pytest.mark.skip -def test_example(): - pass -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("test.py") - visitor.visit(tree) - - assert len(visitor.violations) == 1 - violation = visitor.violations[0] - assert violation["reason"] is None or violation["reason"] == "" - - def test_allows_skip_with_strong_justification(self): - """T016: Test skip with strong justification (allowed if policy changes)""" - code = """ -import pytest - -@pytest.mark.skip(reason="Blocked by external API downtime - Issue #123") -def test_example(): - pass -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("test.py") - visitor.visit(tree) - - # Currently all skips are detected - policy can be changed later - assert len(visitor.violations) == 1 - violation = visitor.violations[0] - assert "Blocked by external API downtime" in violation["reason"] - - def test_detects_nested_decorators(self): - """T017: Test nested decorators handling""" - code = """ -import pytest - -@pytest.mark.asyncio -@pytest.mark.skip(reason="TODO") -def test_example(): - pass -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("test.py") - visitor.visit(tree) - - assert len(visitor.violations) == 1 - assert "pytest.mark.skip" in visitor.violations[0]["decorator"] - - def test_handles_non_test_files(self): - """T018: Test non-test file handling (no false positives)""" - # Even though this contains "skip", it's not a test file - # and shouldn't trigger violations - assert not is_test_file("utils/helper.py") - assert not is_test_file("src/processor.py") - assert is_test_file("tests/test_example.py") - assert is_test_file("test_foo.py") - - def test_performance_on_large_files(self): - """T019: Test performance <100ms on large files (200ms in CI)""" - # Create a large test file with 500 test functions - code_parts = ["import pytest\n\n"] - for i in range(500): - code_parts.append( - f""" -def test_example_{i}(): - assert True -""" - ) - - code = "".join(code_parts) - - start = time.time() - tree = ast.parse(code) - visitor = SkipDetectorVisitor("large_test.py") - visitor.visit(tree) - elapsed = (time.time() - start) * 1000 # Convert to ms - - # Use relaxed threshold in CI (slower, more variable environment) - threshold = 200 if os.environ.get("CI") else 100 - assert elapsed < threshold, f"Performance too slow: {elapsed}ms (threshold: {threshold}ms)" - - -class TestSkipDetectorHelpers: - """Test helper functions for skip detection.""" - - def test_is_test_file_recognizes_test_patterns(self): - """Test that is_test_file correctly identifies test files.""" - assert is_test_file("tests/test_foo.py") - assert is_test_file("test_bar.py") - assert is_test_file("tests/integration/test_api.py") - assert not is_test_file("src/main.py") - assert not is_test_file("utils/helper.py") - - def test_check_file_returns_violations(self): - """Test that check_file returns violations for test files with skips.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", prefix="test_", delete=False) as f: - f.write( - """ -import pytest - -@pytest.mark.skip -def test_example(): - pass -""" - ) - f.flush() - temp_path = f.name - - try: - violations = check_file(temp_path) - assert len(violations) > 0 - finally: - Path(temp_path).unlink() - - def test_format_violation_produces_readable_output(self): - """Test that format_violation produces human-readable output.""" - violation = { - "file": "tests/test_example.py", - "line": 10, - "function": "test_authentication", - "decorator": "@pytest.mark.skip", - "reason": "TODO", - } - - formatted = format_violation(violation) - assert "tests/test_example.py" in formatted - assert "10" in formatted - assert "test_authentication" in formatted - assert "@pytest.mark.skip" in formatted - - -class TestSkipDetectorEdgeCases: - """Test edge cases and error handling.""" - - def test_handles_empty_file(self): - """Test that empty files are handled gracefully.""" - code = "" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("empty.py") - visitor.visit(tree) - assert len(visitor.violations) == 0 - - def test_handles_file_with_only_comments(self): - """Test files with only comments.""" - code = """ -# This is a comment -# Another comment -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("comments.py") - visitor.visit(tree) - assert len(visitor.violations) == 0 - - def test_detects_multiple_skips_in_one_file(self): - """Test detection of multiple skip decorators in a single file.""" - code = """ -import pytest - -@pytest.mark.skip -def test_one(): - pass - -@pytest.mark.skip(reason="TODO") -def test_two(): - pass - -@skip -def test_three(): - pass -""" - tree = ast.parse(code) - visitor = SkipDetectorVisitor("test.py") - visitor.visit(tree) - assert len(visitor.violations) == 3 diff --git a/tests/enforcement/test_skip_pattern_detector.py b/tests/enforcement/test_skip_pattern_detector.py deleted file mode 100644 index d2ee910a..00000000 --- a/tests/enforcement/test_skip_pattern_detector.py +++ /dev/null @@ -1,431 +0,0 @@ -""" -Tests for SkipPatternDetector - multi-language skip pattern detection. -""" - -from codeframe.enforcement import SkipPatternDetector - - -class TestSkipPatternDetectorPython: - """Test Python skip pattern detection.""" - - def test_detects_simple_skip_decorator(self, tmp_path): - """Test detection of @skip decorator""" - # Add language marker - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - test_file = tmp_path / "test_example.py" - test_file.write_text( - """ -import pytest - -@skip -def test_something(): - pass -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - assert violations[0].pattern == "@skip" - assert "test_something" in violations[0].context - - def test_detects_pytest_mark_skip(self, tmp_path): - """Test detection of @pytest.mark.skip""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - test_file = tmp_path / "test_example.py" - test_file.write_text( - """ -import pytest - -@pytest.mark.skip(reason="Not implemented yet") -def test_something(): - pass -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - assert "pytest.mark.skip" in violations[0].pattern - assert violations[0].reason == "Not implemented yet" - - def test_detects_unittest_skip(self, tmp_path): - """Test detection of @unittest.skip""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - test_file = tmp_path / "test_example.py" - test_file.write_text( - """ -import unittest - -class TestExample(unittest.TestCase): - @unittest.skip("Skipping test") - def test_something(self): - pass -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - assert "unittest.skip" in violations[0].pattern - - def test_detects_multiple_skip_decorators(self, tmp_path): - """Test detection of multiple skip decorators in one file""" - (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]") - - test_file = tmp_path / "test_example.py" - test_file.write_text( - """ -import pytest - -@pytest.mark.skip -def test_one(): - pass - -@skip -def test_two(): - pass - -def test_three(): - pass -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 2 - - -class TestSkipPatternDetectorJavaScript: - """Test JavaScript/TypeScript skip pattern detection.""" - - def test_detects_it_skip(self, tmp_path): - """Test detection of it.skip in Jest""" - (tmp_path / "package.json").write_text('{"devDependencies": {"jest": "^29.0.0"}}') - - test_file = tmp_path / "example.test.js" - test_file.write_text( - """ -describe('User', () => { - it.skip('should authenticate', () => { - // Test skipped - }); -}); -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - # Pattern includes regex escapes - assert "it" in violations[0].pattern and "skip" in violations[0].pattern - - def test_detects_xit(self, tmp_path): - """Test detection of xit""" - test_file = tmp_path / "example.test.js" - test_file.write_text( - """ -xit('should work', () => { - expect(true).toBe(true); -}); -""" - ) - (tmp_path / "package.json").write_text('{"devDependencies": {"jest": "^29.0.0"}}') - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - assert "xit" in violations[0].pattern - - def test_detects_describe_skip(self, tmp_path): - """Test detection of describe.skip""" - (tmp_path / "tsconfig.json").write_text("{}") - (tmp_path / "package.json").write_text('{"devDependencies": {"jest": "^29.0.0"}}') - - test_file = tmp_path / "example.test.ts" - test_file.write_text( - """ -describe.skip('User module', () => { - it('should work', () => {}); -}); -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - assert "describe" in violations[0].pattern and "skip" in violations[0].pattern - - -class TestSkipPatternDetectorGo: - """Test Go skip pattern detection.""" - - def test_detects_t_skip(self, tmp_path): - """Test detection of t.Skip() in Go""" - (tmp_path / "go.mod").write_text("module example.com/myapp\n\ngo 1.21") - - test_file = tmp_path / "example_test.go" - test_file.write_text( - """ -package main - -import "testing" - -func TestExample(t *testing.T) { - t.Skip("Not ready yet") - // test code -} -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - assert "t" in violations[0].pattern and "Skip" in violations[0].pattern - - def test_detects_build_ignore_tag(self, tmp_path): - """Test detection of // +build ignore""" - test_file = tmp_path / "example_test.go" - test_file.write_text( - """ -// +build ignore - -package main -""" - ) - (tmp_path / "go.mod").write_text("module example.com/myapp") - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 1 - - -class TestSkipPatternDetectorRust: - """Test Rust skip pattern detection.""" - - def test_detects_ignore_attribute(self, tmp_path): - """Test detection of #[ignore] in Rust""" - (tmp_path / "Cargo.toml").write_text('[package]\nname = "myapp"') - - # Create tests directory and file - tests_dir = tmp_path / "tests" - tests_dir.mkdir() - test_file = tests_dir / "example.rs" - test_file.write_text( - """ -#[test] -#[ignore] -fn test_something() { - assert_eq!(1, 1); -} -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) >= 1 - # Check that at least one violation has #[ignore] - assert any("#[ignore]" in v.pattern for v in violations) - - -class TestSkipPatternDetectorJava: - """Test Java skip pattern detection.""" - - def test_detects_ignore_annotation(self, tmp_path): - """Test detection of @Ignore in Java""" - (tmp_path / "pom.xml").write_text("") - - test_file = tmp_path / "src" / "test" / "java" / "TestExample.java" - test_file.parent.mkdir(parents=True) - test_file.write_text( - """ -import org.junit.Test; -import org.junit.Ignore; - -public class TestExample { - @Test - @Ignore("Not ready") - public void testSomething() { - // test code - } -} -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) >= 1 - assert any("@Ignore" in v.pattern for v in violations) - - def test_detects_disabled_annotation(self, tmp_path): - """Test detection of @Disabled in JUnit 5""" - (tmp_path / "pom.xml").write_text("") - - test_file = tmp_path / "src" / "test" / "java" / "TestExample.java" - test_file.parent.mkdir(parents=True) - test_file.write_text( - """ -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Disabled; - -public class TestExample { - @Test - @Disabled - public void testSomething() {} -} -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) >= 1 - assert any("@Disabled" in v.pattern for v in violations) - - -class TestSkipPatternDetectorRuby: - """Test Ruby/RSpec skip pattern detection.""" - - def test_detects_skip_keyword(self, tmp_path): - """Test detection of 'skip' in RSpec""" - (tmp_path / "Gemfile").write_text("gem 'rspec'") - - test_file = tmp_path / "spec" / "example_spec.rb" - test_file.parent.mkdir() - test_file.write_text( - """ -RSpec.describe 'User' do - it 'authenticates' do - skip 'Not implemented' - expect(true).to be true - end -end -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) >= 1 - assert any("skip" in v.pattern for v in violations) - - def test_detects_pending_keyword(self, tmp_path): - """Test detection of 'pending' in RSpec""" - (tmp_path / "Gemfile").write_text("gem 'rspec'") - - test_file = tmp_path / "spec" / "example_spec.rb" - test_file.parent.mkdir() - test_file.write_text( - """ -RSpec.describe 'User' do - it 'works' do - pending 'Need to fix' - end -end -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) >= 1 - assert any("pending" in v.pattern for v in violations) - - -class TestSkipPatternDetectorCSharp: - """Test C# skip pattern detection.""" - - def test_detects_ignore_attribute(self, tmp_path): - """Test detection of [Ignore] in C#""" - (tmp_path / "MyApp.csproj").write_text('') - - test_file = tmp_path / "TestExample.cs" - test_file.write_text( - """ -using NUnit.Framework; - -[TestFixture] -public class TestExample -{ - [Test] - [Ignore("Not ready")] - public void TestSomething() - { - Assert.AreEqual(1, 1); - } -} -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) >= 1 - assert any("Ignore" in v.pattern for v in violations) - - -class TestSkipPatternDetectorEdgeCases: - """Test edge cases and error handling.""" - - def test_handles_no_skip_patterns(self, tmp_path): - """Test that clean code returns no violations""" - test_file = tmp_path / "test_example.py" - test_file.write_text( - """ -def test_something(): - assert True -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 0 - - def test_handles_syntax_errors_gracefully(self, tmp_path): - """Test that syntax errors don't crash the detector""" - test_file = tmp_path / "test_example.py" - test_file.write_text( - """ -def test_something( - # Missing closing paren - syntax error - assert True -""" - ) - - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - # Should not raise exception, just return empty list - assert isinstance(violations, list) - - def test_handles_empty_project(self, tmp_path): - """Test empty project returns no violations""" - detector = SkipPatternDetector(str(tmp_path)) - violations = detector.detect_all() - - assert len(violations) == 0 - - def test_handles_missing_files(self, tmp_path): - """Test that missing files are handled gracefully""" - (tmp_path / "pyproject.toml").write_text("") - - detector = SkipPatternDetector(str(tmp_path)) - # Should not crash even if test files don't exist - violations = detector.detect_all() - - assert isinstance(violations, list) diff --git a/tests/git/test_git_auto_commit.py b/tests/git/test_git_auto_commit.py deleted file mode 100644 index e6335adc..00000000 --- a/tests/git/test_git_auto_commit.py +++ /dev/null @@ -1,490 +0,0 @@ -"""Tests for Git Auto-Commit functionality (cf-44).""" - -import pytest -import git -from codeframe.git.workflow_manager import GitWorkflowManager -from codeframe.persistence.database import Database - - -@pytest.fixture -def temp_git_repo(tmp_path): - """Create a temporary git repository for testing.""" - repo_path = tmp_path / "test_repo" - repo_path.mkdir() - - # Initialize git repo - repo = git.Repo.init(repo_path) - - # Configure git user (required for commits) - repo.config_writer().set_value("user", "name", "Test User").release() - repo.config_writer().set_value("user", "email", "test@example.com").release() - - # Create initial commit - readme = repo_path / "README.md" - readme.write_text("# Test Project") - repo.index.add(["README.md"]) - repo.index.commit("Initial commit") - - return repo_path - - -@pytest.fixture -def db(tmp_path): - """Create a test database.""" - db_path = tmp_path / "test.db" - database = Database(db_path) - database.initialize() - - # Create a test project - database.create_project( - name="Test Project", description="Test project for git auto-commit tests" - ) - - yield database - - database.close() - - -@pytest.fixture -def workflow_manager(temp_git_repo, db): - """Create a GitWorkflowManager instance.""" - return GitWorkflowManager(temp_git_repo, db) - - -class TestCommitMessageGeneration: - """Tests for commit message generation.""" - - def test_generate_message_for_feat_task(self, workflow_manager): - """Test generating commit message for a feature task.""" - task = { - "id": 1, - "task_number": "1.5.2", - "title": "Implement user authentication", - "description": "Add JWT token-based authentication for API endpoints", - } - files = ["codeframe/auth/user.py", "tests/test_auth.py"] - - message = workflow_manager._generate_commit_message(task, files) - - # Should follow conventional commit format - assert message.startswith("feat(1.5.2):") - assert "Implement user authentication" in message - assert "codeframe/auth/user.py" in message - assert "tests/test_auth.py" in message - - def test_generate_message_for_fix_task(self, workflow_manager): - """Test generating commit message for a bugfix task.""" - task = { - "id": 2, - "task_number": "2.3.1", - "title": "Fix authentication token expiry", - "description": "Correct token validation to properly check expiration", - } - files = ["codeframe/auth/token.py"] - - message = workflow_manager._generate_commit_message(task, files) - - # Should detect "fix" from title - assert message.startswith("fix(2.3.1):") - assert "Fix authentication token expiry" in message - - def test_generate_message_for_test_task(self, workflow_manager): - """Test generating commit message for a test task.""" - task = { - "id": 3, - "task_number": "1.5.4", - "title": "Add unit tests for authentication", - "description": "Write comprehensive test coverage for auth module", - } - files = ["tests/test_auth.py"] - - message = workflow_manager._generate_commit_message(task, files) - - # Should detect "test" from title - assert message.startswith("test(1.5.4):") - - def test_infer_commit_type_from_keywords(self, workflow_manager): - """Test commit type inference from task title keywords.""" - # Test "implement" → feat - assert workflow_manager._infer_commit_type("Implement user login", "") == "feat" - - # Test "add" → feat - assert workflow_manager._infer_commit_type("Add password hashing", "") == "feat" - - # Test "fix" → fix - assert workflow_manager._infer_commit_type("Fix login bug", "") == "fix" - - # Test "refactor" → refactor - assert workflow_manager._infer_commit_type("Refactor auth module", "") == "refactor" - - # Test "test" → test - assert workflow_manager._infer_commit_type("Add tests for login", "") == "test" - - # Test "document" → docs - assert workflow_manager._infer_commit_type("Document API endpoints", "") == "docs" - - # Default to "feat" if no keyword matches - assert workflow_manager._infer_commit_type("Something else", "") == "feat" - - def test_generate_message_with_file_list(self, workflow_manager): - """Test that generated message includes modified files.""" - task = { - "id": 1, - "task_number": "1.1.1", - "title": "Create user model", - "description": "Define User model with fields", - } - files = ["codeframe/models/user.py", "tests/test_user_model.py"] - - message = workflow_manager._generate_commit_message(task, files) - - # Should list all files - assert "codeframe/models/user.py" in message - assert "tests/test_user_model.py" in message - - def test_generate_message_without_description(self, workflow_manager): - """Test generating message when task has no description.""" - task = { - "id": 1, - "task_number": "1.1.1", - "title": "Create user model", - "description": None, - } - files = ["codeframe/models/user.py"] - - message = workflow_manager._generate_commit_message(task, files) - - # Should still generate valid message - assert message.startswith("feat(1.1.1):") - assert "Create user model" in message - - -class TestCommitCreation: - """Tests for git commit creation.""" - - def test_commit_single_file_change(self, workflow_manager, temp_git_repo): - """Test creating a commit with a single file change.""" - # Create a test file - test_file = temp_git_repo / "test.py" - test_file.write_text("print('hello')") - - task = { - "id": 1, - "project_id": 1, - "task_number": "1.1.1", - "title": "Add hello script", - "description": "Simple hello world script", - } - files = ["test.py"] - - # Commit the changes - commit_hash = workflow_manager.commit_task_changes( - task=task, files_modified=files, agent_id="test-agent" - ) - - # Verify commit was created - assert commit_hash is not None - assert len(commit_hash) == 40 # SHA-1 hash is 40 chars - - # Verify commit exists in git log - repo = git.Repo(temp_git_repo) - commits = list(repo.iter_commits()) - assert len(commits) == 2 # Initial + our commit - assert commits[0].hexsha == commit_hash - - def test_commit_multiple_files(self, workflow_manager, temp_git_repo): - """Test creating a commit with multiple file changes.""" - # Create multiple test files - file1 = temp_git_repo / "file1.py" - file2 = temp_git_repo / "file2.py" - file1.write_text("# File 1") - file2.write_text("# File 2") - - task = { - "id": 2, - "project_id": 1, - "task_number": "1.1.2", - "title": "Add multiple files", - "description": "Create file1 and file2", - } - files = ["file1.py", "file2.py"] - - commit_hash = workflow_manager.commit_task_changes( - task=task, files_modified=files, agent_id="test-agent" - ) - - assert commit_hash is not None - - # Verify both files are in the commit - repo = git.Repo(temp_git_repo) - commit = repo.commit(commit_hash) - changed_files = [item.a_path for item in commit.diff(commit.parents[0])] - assert "file1.py" in changed_files - assert "file2.py" in changed_files - - def test_commit_message_in_git_log(self, workflow_manager, temp_git_repo): - """Test that commit message appears correctly in git log.""" - test_file = temp_git_repo / "hello.py" - test_file.write_text("print('hello')") - - task = { - "id": 1, - "project_id": 1, - "task_number": "1.5.3", - "title": "Add hello script", - "description": "Script for greeting", - } - - commit_hash = workflow_manager.commit_task_changes( - task=task, files_modified=["hello.py"], agent_id="test-agent" - ) - - # Get commit message from git - repo = git.Repo(temp_git_repo) - commit = repo.commit(commit_hash) - - assert "feat(1.5.3):" in commit.message - assert "Add hello script" in commit.message - - def test_commit_on_feature_branch(self, workflow_manager, temp_git_repo): - """Test that commit is created on current branch, not main.""" - # Create and checkout a feature branch - repo = git.Repo(temp_git_repo) - feature_branch = repo.create_head("feature-test") - feature_branch.checkout() - - # Create file and commit - test_file = temp_git_repo / "feature.py" - test_file.write_text("# Feature") - - task = { - "id": 1, - "project_id": 1, - "task_number": "2.1.1", - "title": "Add feature", - "description": "New feature", - } - - commit_hash = workflow_manager.commit_task_changes( - task=task, files_modified=["feature.py"], agent_id="test-agent" - ) - - # Verify commit is on feature branch - assert repo.active_branch.name == "feature-test" - assert commit_hash == repo.head.commit.hexsha - - # Verify commit is NOT on master/main (test repos may use either) - try: - repo.heads['master'].checkout() - except (AttributeError, IndexError): - repo.heads['main'].checkout() - assert commit_hash != repo.head.commit.hexsha - - def test_commit_returns_valid_sha(self, workflow_manager, temp_git_repo): - """Test that returned commit hash is a valid SHA-1.""" - test_file = temp_git_repo / "test.py" - test_file.write_text("# Test") - - task = { - "id": 1, - "project_id": 1, - "task_number": "1.1.1", - "title": "Test commit", - "description": None, - } - - commit_hash = workflow_manager.commit_task_changes( - task=task, files_modified=["test.py"], agent_id="test-agent" - ) - - # SHA-1 is 40 hexadecimal characters - assert len(commit_hash) == 40 - assert all(c in "0123456789abcdef" for c in commit_hash) - - -class TestChangelogIntegration: - """Tests for changelog database integration.""" - - def test_record_commit_in_changelog(self, workflow_manager, temp_git_repo, db): - """Test that commit is recorded in changelog table.""" - # Create test project - project_id = 1 - - # Create and commit file - test_file = temp_git_repo / "test.py" - test_file.write_text("# Test") - - task = { - "id": 5, - "project_id": project_id, - "task_number": "1.2.1", - "title": "Add feature", - "description": "Feature description", - } - - commit_hash = workflow_manager.commit_task_changes( - task=task, files_modified=["test.py"], agent_id="backend-agent-1" - ) - - # Query changelog - cursor = db.conn.cursor() - cursor.execute( - """ - SELECT * FROM changelog - WHERE task_id = ? AND action = 'commit' - """, - (task["id"],), - ) - - row = cursor.fetchone() - assert row is not None - - entry = dict(row) - assert entry["project_id"] == project_id - assert entry["agent_id"] == "backend-agent-1" - assert entry["task_id"] == task["id"] - assert entry["action"] == "commit" - - # Verify details JSON - import json - - details = json.loads(entry["details"]) - assert details["commit_hash"] == commit_hash - assert "feat(1.2.1):" in details["commit_message"] - assert "test.py" in details["files_modified"] - - def test_changelog_entry_structure(self, workflow_manager, temp_git_repo, db): - """Test that changelog entry has correct structure.""" - test_file = temp_git_repo / "test.py" - test_file.write_text("# Test") - - task = { - "id": 10, - "project_id": 1, - "task_number": "3.1.1", - "title": "Test task", - "description": "Test description", - } - - workflow_manager.commit_task_changes( - task=task, files_modified=["test.py"], agent_id="test-agent" - ) - - # Get changelog entry - cursor = db.conn.cursor() - cursor.execute("SELECT * FROM changelog WHERE task_id = ?", (task["id"],)) - entry = dict(cursor.fetchone()) - - # Verify required fields - assert "id" in entry - assert "project_id" in entry - assert "agent_id" in entry - assert "task_id" in entry - assert "action" in entry - assert "details" in entry - assert "timestamp" in entry - - def test_query_changelog_by_task(self, workflow_manager, temp_git_repo, db): - """Test querying changelog entries by task_id.""" - test_file = temp_git_repo / "test.py" - test_file.write_text("# Test") - - task = { - "id": 15, - "project_id": 1, - "task_number": "4.1.1", - "title": "Query test", - "description": "Test querying", - } - - workflow_manager.commit_task_changes( - task=task, files_modified=["test.py"], agent_id="test-agent" - ) - - # Query by task_id - cursor = db.conn.cursor() - cursor.execute("SELECT * FROM changelog WHERE task_id = ?", (task["id"],)) - entries = cursor.fetchall() - - assert len(entries) == 1 - assert dict(entries[0])["task_id"] == task["id"] - - def test_query_changelog_by_agent(self, workflow_manager, temp_git_repo, db): - """Test querying changelog entries by agent_id.""" - test_file = temp_git_repo / "test.py" - test_file.write_text("# Test") - - task = { - "id": 20, - "project_id": 1, - "task_number": "5.1.1", - "title": "Agent query test", - "description": "Test agent query", - } - - workflow_manager.commit_task_changes( - task=task, files_modified=["test.py"], agent_id="backend-agent-1" - ) - - # Query by agent_id - cursor = db.conn.cursor() - cursor.execute("SELECT * FROM changelog WHERE agent_id = ?", ("backend-agent-1",)) - entries = cursor.fetchall() - - assert len(entries) >= 1 - # Verify at least one entry matches our agent - agent_ids = [dict(e)["agent_id"] for e in entries] - assert "backend-agent-1" in agent_ids - - -class TestErrorHandling: - """Tests for error handling.""" - - def test_handle_empty_file_list(self, workflow_manager): - """Test handling empty file list.""" - task = { - "id": 1, - "project_id": 1, - "task_number": "1.1.1", - "title": "Empty commit", - "description": "No files", - } - - # Should raise ValueError or skip commit - with pytest.raises(ValueError, match="No files to commit"): - workflow_manager.commit_task_changes( - task=task, files_modified=[], agent_id="test-agent" - ) - - def test_handle_nonexistent_files(self, workflow_manager, temp_git_repo): - """Test handling files that don't exist in working directory.""" - task = { - "id": 1, - "project_id": 1, - "task_number": "1.1.1", - "title": "Nonexistent files", - "description": "Files don't exist", - } - - # Should raise error for nonexistent files - with pytest.raises(Exception): # Could be git.GitCommandError or FileNotFoundError - workflow_manager.commit_task_changes( - task=task, files_modified=["nonexistent.py"], agent_id="test-agent" - ) - - def test_handle_missing_task_fields(self, workflow_manager, temp_git_repo): - """Test handling task with missing required fields.""" - test_file = temp_git_repo / "test.py" - test_file.write_text("# Test") - - # Task missing task_number - task = { - "id": 1, - "project_id": 1, - "title": "Test", - } - - with pytest.raises(KeyError): - workflow_manager.commit_task_changes( - task=task, files_modified=["test.py"], agent_id="test-agent" - ) diff --git a/tests/git/test_git_workflow_manager.py b/tests/git/test_git_workflow_manager.py deleted file mode 100644 index 5e5f7be4..00000000 --- a/tests/git/test_git_workflow_manager.py +++ /dev/null @@ -1,874 +0,0 @@ -"""Test suite for GitWorkflowManager. - -Following TDD methodology: RED → GREEN → REFACTOR -""" - -import pytest -import tempfile -from pathlib import Path -import git - -from codeframe.git.workflow_manager import GitWorkflowManager -from codeframe.persistence.database import Database - - -@pytest.fixture -def temp_git_repo(): - """Create a temporary git repository for testing.""" - with tempfile.TemporaryDirectory() as tmpdir: - repo_path = Path(tmpdir) - - # Initialize git repo with main branch - repo = git.Repo.init(repo_path, initial_branch="main") - - # Create initial commit - test_file = repo_path / "README.md" - test_file.write_text("# Test Project\n") - repo.index.add(["README.md"]) - repo.index.commit("Initial commit") - - yield repo_path, repo - - -@pytest.fixture -def test_db(): - """Create a test database in memory.""" - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: - db_path = Path(tmp.name) - - db = Database(db_path) - db.initialize() - - yield db - - # Close async connection if it was opened (prevents hanging) - if db._async_conn: - import asyncio - - try: - asyncio.get_event_loop().run_until_complete(db.close_async()) - except RuntimeError: - asyncio.run(db.close_async()) - db.close() - db_path.unlink() - - -@pytest.fixture -def workflow_manager(temp_git_repo, test_db): - """Create GitWorkflowManager instance for testing.""" - repo_path, repo = temp_git_repo - return GitWorkflowManager(repo_path, test_db) - - -class TestGitWorkflowManagerInitialization: - """Test GitWorkflowManager initialization.""" - - def test_init_with_valid_repo(self, temp_git_repo, test_db): - """Test initialization with valid git repository.""" - repo_path, repo = temp_git_repo - manager = GitWorkflowManager(repo_path, test_db) - - assert manager.project_root == repo_path - assert manager.db == test_db - assert manager.repo is not None - assert isinstance(manager.repo, git.Repo) - - def test_init_with_non_git_directory(self, test_db): - """Test initialization with non-git directory raises error.""" - with tempfile.TemporaryDirectory() as tmpdir: - with pytest.raises(git.InvalidGitRepositoryError): - GitWorkflowManager(Path(tmpdir), test_db) - - def test_init_with_nonexistent_path(self, test_db): - """Test initialization with nonexistent path raises error.""" - with pytest.raises(git.NoSuchPathError): - GitWorkflowManager(Path("/nonexistent/path"), test_db) - - -class TestCreateFeatureBranch: - """Test feature branch creation.""" - - def test_create_feature_branch_basic(self, workflow_manager, temp_git_repo): - """Test creating a feature branch with basic inputs.""" - repo_path, repo = temp_git_repo - - branch_name = workflow_manager.create_feature_branch("2.1", "User Authentication") - - assert branch_name == "issue-2.1-user-authentication" - assert branch_name in [b.name for b in repo.branches] - - def test_create_feature_branch_sanitizes_title(self, workflow_manager, temp_git_repo): - """Test that branch name properly sanitizes special characters.""" - repo_path, repo = temp_git_repo - - branch_name = workflow_manager.create_feature_branch( - "3.5", "Add User's Profile & Settings (V2)" - ) - - assert branch_name == "issue-3.5-add-users-profile-settings-v2" - assert "/" not in branch_name - assert "&" not in branch_name - assert "(" not in branch_name - - def test_create_feature_branch_long_title_truncated(self, workflow_manager, temp_git_repo): - """Test that very long titles are truncated.""" - repo_path, repo = temp_git_repo - - long_title = "A" * 100 # Very long title - branch_name = workflow_manager.create_feature_branch("1.1", long_title) - - # Branch name should be truncated but still valid - assert len(branch_name) <= 63 # Git ref name length limit - assert branch_name.startswith("issue-1.1-") - - def test_create_feature_branch_already_exists(self, workflow_manager, temp_git_repo): - """Test creating branch that already exists raises error.""" - repo_path, repo = temp_git_repo - - # Create first branch - workflow_manager.create_feature_branch("2.1", "User Auth") - - # Try to create same branch again - with pytest.raises(ValueError, match="Branch .* already exists"): - workflow_manager.create_feature_branch("2.1", "User Auth") - - def test_create_feature_branch_with_dirty_working_tree(self, workflow_manager, temp_git_repo): - """Test creating branch with uncommitted changes.""" - repo_path, repo = temp_git_repo - - # Create uncommitted changes - test_file = repo_path / "test.txt" - test_file.write_text("Uncommitted changes") - - # Should still create branch successfully - branch_name = workflow_manager.create_feature_branch("2.1", "Test Feature") - assert branch_name in [b.name for b in repo.branches] - - def test_create_feature_branch_stores_in_database(self, workflow_manager, test_db): - """Test that branch creation is recorded in database.""" - # First create an issue in database - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="User Authentication", - status=TaskStatus.PENDING, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - # Create branch - branch_name = workflow_manager.create_feature_branch("2.1", "User Authentication") - - # Verify in database - branch_record = test_db.get_branch_for_issue(issue_id) - assert branch_record is not None - assert branch_record["branch_name"] == branch_name - assert branch_record["status"] == "active" - - -class TestMergeToMain: - """Test merging feature branches to main.""" - - @pytest.mark.asyncio - async def test_merge_to_main_success(self, workflow_manager, temp_git_repo, test_db): - """Test successful merge to main when all tasks complete.""" - repo_path, repo = temp_git_repo - - # Setup: create issue and tasks in database - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="User Auth", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - # Create tasks for issue - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.1.1", - parent_issue_number="2.1", - title="Task 1", - description="Test task 1", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.1.2", - parent_issue_number="2.1", - title="Task 2", - description="Test task 2", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create feature branch - branch_name = workflow_manager.create_feature_branch("2.1", "User Auth") - - # Switch to feature branch and make a commit - repo.git.checkout(branch_name) - test_file = repo_path / "feature.txt" - test_file.write_text("Feature implementation") - repo.index.add(["feature.txt"]) - repo.index.commit("Implement user auth feature") - - # Switch back to main - repo.git.checkout("main") - - # Merge to main - result = await workflow_manager.merge_to_main("2.1") - - assert result["status"] == "merged" - assert result["branch_name"] == branch_name - assert "merge_commit" in result - assert repo.active_branch.name == "main" - - # Verify merge commit exists - assert (repo_path / "feature.txt").exists() - - @pytest.mark.asyncio - async def test_merge_to_main_incomplete_tasks(self, workflow_manager, temp_git_repo, test_db): - """Test merge fails when not all tasks are completed.""" - repo_path, repo = temp_git_repo - - # Setup: create issue with incomplete tasks - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="User Auth", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - # Create incomplete task - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.1.1", - parent_issue_number="2.1", - title="Task 1", - description="Test task 1", - status=TaskStatus.IN_PROGRESS, # NOT COMPLETED - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create feature branch - workflow_manager.create_feature_branch("2.1", "User Auth") - - # Try to merge - with pytest.raises(ValueError, match="Cannot merge.*incomplete tasks"): - await workflow_manager.merge_to_main("2.1") - - @pytest.mark.asyncio - async def test_merge_to_main_nonexistent_issue(self, workflow_manager): - """Test merge fails for nonexistent issue.""" - with pytest.raises(ValueError, match="Issue.*not found"): - await workflow_manager.merge_to_main("99.99") - - def test_merge_to_main_conflict_handling(self, workflow_manager, temp_git_repo, test_db): - """Test merge conflict detection and handling.""" - repo_path, repo = temp_git_repo - - # Setup: create issue with completed tasks - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="Conflict Test", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.1.1", - parent_issue_number="2.1", - title="Task 1", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create conflicting changes in main - test_file = repo_path / "conflict.txt" - test_file.write_text("Main branch content") - repo.index.add(["conflict.txt"]) - repo.index.commit("Main branch change") - - # Create feature branch and conflicting change - branch_name = workflow_manager.create_feature_branch("2.1", "Conflict Test") - repo.git.checkout(branch_name) - test_file.write_text("Feature branch content") - repo.index.add(["conflict.txt"]) - repo.index.commit("Feature branch change") - repo.git.checkout("main") - - # Try to merge - should detect conflict or raise ValueError - try: - workflow_manager.merge_to_main("2.1") - # If merge succeeded without conflict, that's also valid (auto-merge) - assert True - except (git.GitCommandError, ValueError): - # Expected - conflict detected - assert True - - @pytest.mark.asyncio - async def test_merge_to_main_updates_database(self, workflow_manager, temp_git_repo, test_db): - """Test that merge updates database tracking.""" - repo_path, repo = temp_git_repo - - # Setup complete issue - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="Test", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.1.1", - parent_issue_number="2.1", - title="Task 1", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create and merge branch - branch_name = workflow_manager.create_feature_branch("2.1", "Test") - repo.git.checkout(branch_name) - (repo_path / "test.txt").write_text("test") - repo.index.add(["test.txt"]) - repo.index.commit("Test commit") - repo.git.checkout("main") - - await workflow_manager.merge_to_main("2.1") - - # Check database was updated - # get_all_branches_for_issue returns all branches (including merged) - branches = test_db.get_all_branches_for_issue(issue_id) - assert len(branches) > 0 - branch_record = branches[-1] # Get most recent - assert branch_record["status"] == "merged" - assert branch_record["merge_commit"] is not None - - -class TestIsIssueComplete: - """Test issue completion checking.""" - - @pytest.mark.asyncio - async def test_is_issue_complete_all_tasks_done(self, workflow_manager, test_db): - """Test issue is complete when all tasks are completed.""" - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="Test", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - # Create completed tasks - for i in range(3): - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"2.1.{i+1}", - parent_issue_number="2.1", - title=f"Task {i+1}", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - assert await workflow_manager.is_issue_complete(issue_id) is True - - @pytest.mark.asyncio - async def test_is_issue_complete_with_pending_tasks(self, workflow_manager, test_db): - """Test issue is not complete with pending tasks.""" - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="Test", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - # Create mix of completed and pending tasks - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.1.1", - parent_issue_number="2.1", - title="Task 1", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.1.2", - parent_issue_number="2.1", - title="Task 2", - description="Test", - status=TaskStatus.PENDING, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - assert await workflow_manager.is_issue_complete(issue_id) is False - - @pytest.mark.asyncio - async def test_is_issue_complete_no_tasks(self, workflow_manager, test_db): - """Test issue with no tasks is considered incomplete.""" - from codeframe.core.models import Issue, TaskStatus - - project_id = test_db.create_project( - name="test_project", description="Test project for git workflow tests" - ) - issue = Issue( - project_id=project_id, - issue_number="2.1", - title="Test", - status=TaskStatus.PENDING, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - - assert await workflow_manager.is_issue_complete(issue_id) is False - - -class TestGetCurrentBranch: - """Test getting current branch name.""" - - def test_get_current_branch_main(self, workflow_manager, temp_git_repo): - """Test getting current branch when on main/master.""" - repo_path, repo = temp_git_repo - - # Git may use main or master as default - current = workflow_manager.get_current_branch() - assert current in ["main", "master"] - - def test_get_current_branch_feature(self, workflow_manager, temp_git_repo): - """Test getting current branch when on feature branch.""" - repo_path, repo = temp_git_repo - - # Create and checkout feature branch - workflow_manager.create_feature_branch("2.1", "Test") - repo.git.checkout("issue-2.1-test") - - assert workflow_manager.get_current_branch() == "issue-2.1-test" - - def test_get_current_branch_detached_head(self, workflow_manager, temp_git_repo): - """Test getting current branch in detached HEAD state.""" - repo_path, repo = temp_git_repo - - # Get first commit and checkout in detached HEAD - first_commit = repo.commit("HEAD") - repo.git.checkout(first_commit.hexsha) - - # Should return commit SHA or indicate detached state - current = workflow_manager.get_current_branch() - assert current.startswith("HEAD detached at") or len(current) == 40 # SHA length - - -class TestCheckoutBranch: - """Test branch checkout functionality.""" - - def test_checkout_branch_success(self, workflow_manager, temp_git_repo): - """Test successful branch checkout.""" - repo_path, repo = temp_git_repo - - # Create feature branch - branch_name = workflow_manager.create_feature_branch("2.1", "Test") - - # Checkout branch - workflow_manager.checkout_branch(branch_name) - - assert repo.active_branch.name == branch_name - - def test_checkout_branch_nonexistent(self, workflow_manager, temp_git_repo): - """Test checkout of nonexistent branch raises error.""" - with pytest.raises(git.GitCommandError): - workflow_manager.checkout_branch("nonexistent-branch") - - def test_checkout_branch_with_uncommitted_changes(self, workflow_manager, temp_git_repo): - """Test checkout with uncommitted changes.""" - repo_path, repo = temp_git_repo - - # Create uncommitted changes - test_file = repo_path / "test.txt" - test_file.write_text("Uncommitted") - - # Create feature branch - branch_name = workflow_manager.create_feature_branch("2.1", "Test") - - # Should still checkout (changes carry over) - workflow_manager.checkout_branch(branch_name) - assert repo.active_branch.name == branch_name - assert test_file.exists() - - -class TestEdgeCases: - """Test edge cases and error handling.""" - - def test_empty_issue_number(self, workflow_manager): - """Test creating branch with empty issue number.""" - with pytest.raises(ValueError, match="Issue number cannot be empty"): - workflow_manager.create_feature_branch("", "Test") - - def test_empty_issue_title(self, workflow_manager): - """Test creating branch with empty title.""" - with pytest.raises(ValueError, match="Issue title cannot be empty"): - workflow_manager.create_feature_branch("2.1", "") - - def test_whitespace_only_issue_number(self, workflow_manager): - """Test creating branch with whitespace-only issue number.""" - with pytest.raises(ValueError, match="Issue number cannot be empty"): - workflow_manager.create_feature_branch(" ", "Test") - - def test_special_characters_in_issue_number(self, workflow_manager, temp_git_repo): - """Test issue number with special characters.""" - # Should sanitize and create valid branch - branch_name = workflow_manager.create_feature_branch("2.1-beta", "Test Feature") - assert branch_name == "issue-2.1-beta-test-feature" - - -class TestConventionalCommitMessages: - """Test conventional commit message formatting (T069).""" - - def test_commit_message_format_feat(self, workflow_manager, temp_git_repo): - """ - T069: Test conventional commit format for feature tasks. - - This test should FAIL initially because _generate_commit_message needs updates. - """ - task = { - "id": 1, - "project_id": 1, - "task_number": "cf-1.5.3", - "title": "Add user authentication", - "description": "Implement JWT-based authentication for API endpoints", - } - - message = workflow_manager._generate_commit_message(task, ["auth.py", "jwt_handler.py"]) - - # Assert conventional commit format: (): - assert message.startswith("feat(cf-1.5.3): Add user authentication") - assert "Implement JWT-based authentication" in message - assert "Modified files:" in message - assert "- auth.py" in message - assert "- jwt_handler.py" in message - - def test_commit_message_format_fix(self, workflow_manager, temp_git_repo): - """Test conventional commit format for bug fix tasks.""" - task = { - "id": 2, - "project_id": 1, - "task_number": "cf-2.1.4", - "title": "Fix database connection leak", - "description": "Close connections properly after queries", - } - - message = workflow_manager._generate_commit_message(task, ["database.py"]) - - assert message.startswith("fix(cf-2.1.4): Fix database connection leak") - assert "Close connections properly" in message - - def test_commit_message_format_test(self, workflow_manager, temp_git_repo): - """Test conventional commit format for test tasks.""" - task = { - "id": 3, - "project_id": 1, - "task_number": "cf-3.2.1", - "title": "Add tests for authentication module", - "description": "Comprehensive test coverage for auth", - } - - message = workflow_manager._generate_commit_message(task, ["tests/test_auth.py"]) - - assert message.startswith("test(cf-3.2.1): Add tests for authentication module") - - def test_commit_message_format_refactor(self, workflow_manager, temp_git_repo): - """Test conventional commit format for refactoring tasks.""" - task = { - "id": 4, - "project_id": 1, - "task_number": "cf-4.1.2", - "title": "Refactor user service into separate modules", - "description": "Split monolithic user service", - } - - message = workflow_manager._generate_commit_message(task, ["user_service.py"]) - - assert message.startswith("refactor(cf-4.1.2): Refactor user service into separate modules") - - -class TestCommitErrorHandling: - """Test error handling in commit operations (T072-T073).""" - - def test_commit_with_dirty_working_tree_raises_error(self, workflow_manager, temp_git_repo): - """ - T072: Test that attempting to commit with untracked files raises ValueError. - - This test should FAIL initially because error handling needs to be added. - """ - repo_path, repo = temp_git_repo - - # Create untracked file - (repo_path / "untracked.py").write_text("# untracked") - - task = { - "id": 5, - "project_id": 1, - "task_number": "cf-5.1.1", - "title": "Test dirty tree", - "description": "Should fail", - } - - # Attempting to commit without staging should raise ValueError - # Note: The method should check for dirty state BEFORE staging files - # This is a design choice to ensure commits are intentional - with pytest.raises(ValueError, match="Working tree is dirty"): - # Try to commit a file that doesn't exist in staging - workflow_manager.commit_task_changes( - task=task, files_modified=["nonexistent.py"], agent_id="test-agent" - ) - - def test_commit_failure_logs_warning_non_blocking(self, workflow_manager, temp_git_repo): - """ - T073: Test that commit failures are gracefully handled (log warning, don't raise). - - This is tested at the worker agent level, not at GitWorkflowManager level. - GitWorkflowManager should raise exceptions, worker agents should catch them. - """ - # This test is actually covered in test_backend_worker_auto_commit.py - # test_backend_worker_graceful_commit_failure() - pass - - -class TestDatabaseSHARecording: - """Test commit SHA recording in database (T070-T071).""" - - def test_update_task_commit_sha(self, test_db): - """ - T070: Test that update_task_commit_sha() stores full SHA correctly. - - This test should PASS as the database method already exists. - """ - from codeframe.core.models import TaskStatus - - # Create project - project_id = test_db.create_project( - name="test_project", description="Test project for SHA recording" - ) - - # Create issue - issue_id = test_db.create_issue( - { - "project_id": project_id, - "issue_number": "1.5", - "title": "Test Issue", - "status": TaskStatus.IN_PROGRESS.value, # Convert enum to string - "priority": 0, - "workflow_step": 1, - } - ) - - # Create task - task_id = test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.5.3", - parent_issue_number="1.5", - title="Test Task", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Update with commit SHA - full_sha = "abc123def456789012345678901234567890abcd" - test_db.update_task_commit_sha(task_id, full_sha) - - # Verify it was stored - cursor = test_db.conn.cursor() - cursor.execute("SELECT commit_sha FROM tasks WHERE id = ?", (task_id,)) - result = cursor.fetchone() - - assert result is not None - assert result["commit_sha"] == full_sha - - def test_get_task_by_commit_full_sha(self, test_db): - """ - T071: Test that get_task_by_commit() retrieves task by full SHA. - - This test should PASS as the database method already exists. - """ - from codeframe.core.models import TaskStatus - - # Create project - project_id = test_db.create_project( - name="test_project", description="Test project for SHA lookup" - ) - - # Create issue - issue_id = test_db.create_issue( - { - "project_id": project_id, - "issue_number": "2.3", - "title": "Test Issue", - "status": TaskStatus.IN_PROGRESS.value, - "priority": 0, - "workflow_step": 1, - } - ) - - # Create task - task_id = test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="2.3.1", - parent_issue_number="2.3", - title="Implement feature X", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Store commit SHA - full_sha = "def456abc789012345678901234567890abcdef1" - test_db.update_task_commit_sha(task_id, full_sha) - - # Retrieve by full SHA - task = test_db.get_task_by_commit(full_sha) - - assert task is not None - assert task["id"] == task_id - assert task["task_number"] == "2.3.1" - assert task["commit_sha"] == full_sha - - def test_get_task_by_commit_short_sha(self, test_db): - """ - T071: Test that get_task_by_commit() retrieves task by short SHA (7 chars). - - This test should PASS as the database method supports short SHA. - """ - from codeframe.core.models import TaskStatus - - # Create project - project_id = test_db.create_project( - name="test_project", description="Test project for short SHA lookup" - ) - - # Create issue - issue_id = test_db.create_issue( - { - "project_id": project_id, - "issue_number": "3.1", - "title": "Test Issue", - "status": TaskStatus.IN_PROGRESS.value, - "priority": 0, - "workflow_step": 1, - } - ) - - # Create task - task_id = test_db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="3.1.2", - parent_issue_number="3.1", - title="Fix bug Y", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Store full commit SHA - full_sha = "789abc012345678901234567890abcdef123456" - test_db.update_task_commit_sha(task_id, full_sha) - - # Retrieve by short SHA (first 7 characters) - short_sha = full_sha[:7] - task = test_db.get_task_by_commit(short_sha) - - assert task is not None - assert task["id"] == task_id - assert task["commit_sha"] == full_sha diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 36c46eaa..8d405c4f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,13 +16,12 @@ async def test_something(real_db, test_workspace, mock_llm_api): import json from pathlib import Path -from typing import Any, Generator +from typing import Generator from unittest.mock import AsyncMock, Mock, patch import pytest from codeframe.persistence.database import Database -from codeframe.core.models import AgentMaturity, TaskStatus # ============================================================================= @@ -68,51 +67,6 @@ def real_db_file(tmp_path: Path) -> Generator[Database, None, None]: db.conn.close() -@pytest.fixture -def integration_project(real_db: Database, test_workspace: Path) -> dict[str, Any]: - """Create a complete test project with database and workspace. - - This fixture provides a fully configured project for integration testing, - including: - - Project record in database - - Test workspace directory - - Sample issues and tasks - - Args: - real_db: Real database fixture - test_workspace: Temp directory fixture - - Returns: - dict: Project configuration with keys: - - project_id: Database project ID - - workspace_path: Path to workspace directory - - db: Database instance - """ - project_id = real_db.create_project( - name="integration-test-project", - description="Project for integration testing", - source_type="empty", - workspace_path=str(test_workspace), - ) - - # Create sample issue - issue_id = real_db.create_issue({ - "project_id": project_id, - "issue_number": "INT-001", - "title": "Integration Test Issue", - "description": "Test issue for integration testing", - "priority": 1, - "workflow_step": 1, - }) - - return { - "project_id": project_id, - "issue_id": issue_id, - "workspace_path": test_workspace, - "db": real_db, - } - - # ============================================================================= # File System Fixtures # ============================================================================= @@ -298,119 +252,6 @@ def factory(content: str, input_tokens: int = 100, output_tokens: int = 50): # ============================================================================= -@pytest.fixture -def worker_agent_config(real_db: Database) -> dict[str, Any]: - """Basic worker agent configuration for integration tests. - - Returns: - dict: Agent configuration with real database and test API key. - """ - return { - "agent_id": "test-worker-001", - "agent_type": "backend", - "provider": "anthropic", - "db": real_db, - "maturity": AgentMaturity.D2, - } - - -@pytest.fixture -def registered_agent(real_db: Database, worker_agent_config: dict) -> str: - """Create a registered agent in the database. - - Args: - real_db: Database fixture - worker_agent_config: Agent config fixture - - Returns: - str: Agent ID - """ - agent_id = worker_agent_config["agent_id"] - real_db.create_agent( - agent_id=agent_id, - agent_type=worker_agent_config["agent_type"], - provider=worker_agent_config["provider"], - maturity_level=worker_agent_config["maturity"], - ) - return agent_id - - -# ============================================================================= -# Task Fixtures -# ============================================================================= - - -@pytest.fixture -def sample_task(integration_project: dict) -> dict[str, Any]: - """Create a sample task in the integration project. - - Args: - integration_project: Project fixture - - Returns: - dict: Task data including ID and full task object. - """ - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="INT-001-1", - parent_issue_number="INT-001", - title="Sample Integration Test Task", - description="Create a sample module with tests", - status=TaskStatus.PENDING, - priority=1, - workflow_step=1, - can_parallelize=False, - ) - - task = db.get_task(task_id) - - return { - "id": task_id, - "task": task, - "project_id": project_id, - "issue_id": issue_id, - } - - -@pytest.fixture -def pending_tasks(integration_project: dict) -> list[dict[str, Any]]: - """Create multiple pending tasks for parallel execution testing. - - Args: - integration_project: Project fixture - - Returns: - list: List of task data dicts. - """ - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - tasks = [] - for i in range(3): - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"INT-001-{i+1}", - parent_issue_number="INT-001", - title=f"Parallel Task {i+1}", - description=f"Task {i+1} for parallel execution", - status=TaskStatus.PENDING, - priority=1, - workflow_step=i + 1, - can_parallelize=True, - ) - task = db.get_task(task_id) - tasks.append({"id": task_id, "task": task}) - - return tasks - - # ============================================================================= # Environment Fixtures # ============================================================================= @@ -450,20 +291,6 @@ def clean_env(monkeypatch): # ============================================================================= -@pytest.fixture -def quality_gates_config(integration_project: dict) -> dict[str, Any]: - """Configuration for quality gates integration tests. - - Returns: - dict: Quality gates configuration. - """ - return { - "db": integration_project["db"], - "project_id": integration_project["project_id"], - "project_root": integration_project["workspace_path"], - } - - # ============================================================================= # Markers # ============================================================================= diff --git a/tests/integration/test_blocker_workflow.py b/tests/integration/test_blocker_workflow.py deleted file mode 100644 index f0ce43ea..00000000 --- a/tests/integration/test_blocker_workflow.py +++ /dev/null @@ -1,439 +0,0 @@ -""" -Integration tests for complete blocker workflow. - -Tests T055-T057 from Phase 9: Testing & Validation -""" - -import pytest_asyncio -from datetime import datetime -from codeframe.persistence.database import Database -from codeframe.core.models import BlockerType, BlockerStatus, TaskStatus - - -@pytest_asyncio.fixture -async def db(): - """Create in-memory database for testing.""" - database = Database(":memory:") - database.initialize() - yield database - database.close() - - -@pytest_asyncio.fixture -async def sample_project(db): - """Create a sample project for testing.""" - project_id = db.create_project( - name="Integration Test Project", - repo_path="/tmp/test", - description="Test project for integration tests", - ) - return project_id - - -@pytest_asyncio.fixture -async def sample_tasks(db, sample_project): - """Create multiple sample tasks with dependencies.""" - # Create issues first - issue1_id = db.create_issue( - { - "project_id": sample_project, - "issue_number": "1.0", - "title": "Issue 1", - "description": "First issue", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - - issue2_id = db.create_issue( - { - "project_id": sample_project, - "issue_number": "2.0", - "title": "Issue 2", - "description": "Second issue", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - - issue3_id = db.create_issue( - { - "project_id": sample_project, - "issue_number": "3.0", - "title": "Issue 3", - "description": "Third issue", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - - # Create tasks - task1_id = db.create_task_with_issue( - project_id=sample_project, - issue_id=issue1_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Task 1 - Database Setup", - description="Set up database schema", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - task2_id = db.create_task_with_issue( - project_id=sample_project, - issue_id=issue2_id, - task_number="2.0.1", - parent_issue_number="2.0", - title="Task 2 - API Endpoints", - description="Create API endpoints", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - task3_id = db.create_task_with_issue( - project_id=sample_project, - issue_id=issue3_id, - task_number="3.0.1", - parent_issue_number="3.0", - title="Task 3 - Frontend", - description="Build frontend components", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - return {"task1": task1_id, "task2": task2_id, "task3": task3_id} - - -class TestCompleteBlockerWorkflow: - """Test T055: Integration test for complete blocker workflow (create → display → resolve → resume).""" - - def test_end_to_end_workflow(self, db, sample_project, sample_tasks): - """Test complete blocker lifecycle from creation to agent resume.""" - task_id = sample_tasks["task1"] - - # Step 1: Agent creates blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task_id, - blocker_type=BlockerType.SYNC, - question="Should I use SQLite or PostgreSQL for the database?", - ) - assert blocker_id > 0 - - # Step 2: Verify blocker appears in dashboard (list API) - response = db.list_blockers(sample_project) - assert response["total"] == 1 - assert response["pending_count"] == 1 - assert response["sync_count"] == 1 - assert response["blockers"][0]["id"] == blocker_id - assert ( - response["blockers"][0]["question"] - == "Should I use SQLite or PostgreSQL for the database?" - ) - - # Step 3: User views blocker details - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "PENDING" - assert blocker["agent_id"] == "backend-worker-001" - - # Step 4: User resolves blocker (simulating UI submission) - success = db.resolve_blocker( - blocker_id, "Use SQLite to match existing codebase. PostgreSQL is overkill for MVP." - ) - assert success is True - - # Step 5: Verify blocker status updated - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert ( - blocker["answer"] - == "Use SQLite to match existing codebase. PostgreSQL is overkill for MVP." - ) - assert blocker["resolved_at"] is not None - - # Step 6: Agent polls and gets answer - resolved_blocker = db.get_pending_blocker("backend-worker-001") - assert resolved_blocker is None # No more pending blockers - - blocker_check = db.get_blocker(blocker_id) - assert blocker_check["status"] == BlockerStatus.RESOLVED - assert blocker_check["answer"] is not None - - # Step 7: Verify blocker disappears from pending list - response = db.list_blockers(sample_project, status="PENDING") - assert response["total"] == 0 - - def test_workflow_with_async_blocker(self, db, sample_project, sample_tasks): - """Test workflow with ASYNC blocker (agent continues work).""" - task_id = sample_tasks["task3"] - - # Create ASYNC blocker - blocker_id = db.create_blocker( - agent_id="frontend-worker-001", - project_id=1, - task_id=task_id, - blocker_type=BlockerType.ASYNC, - question="Should the button be blue or green?", - ) - - # Agent can continue working (ASYNC blocker doesn't pause) - # Simulate agent completing other work while blocker pending - - # Later, user resolves blocker - db.resolve_blocker(blocker_id, "Use blue to match brand colors") - - # Agent can incorporate answer later - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert blocker["blocker_type"] == BlockerType.ASYNC - - def test_workflow_with_multiple_sequential_blockers(self, db, sample_project, sample_tasks): - """Test agent creating and resolving multiple blockers sequentially.""" - task_id = sample_tasks["task1"] - - # First blocker - blocker1_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task_id, - blocker_type=BlockerType.SYNC, - question="Question 1", - ) - db.resolve_blocker(blocker1_id, "Answer 1") - - # Second blocker (after first resolved) - blocker2_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task_id, - blocker_type=BlockerType.SYNC, - question="Question 2", - ) - db.resolve_blocker(blocker2_id, "Answer 2") - - # Verify both resolved - blocker1 = db.get_blocker(blocker1_id) - blocker2 = db.get_blocker(blocker2_id) - assert blocker1["status"] == BlockerStatus.RESOLVED - assert blocker2["status"] == BlockerStatus.RESOLVED - - -class TestSyncBlockerPausingDependentTasks: - """Test T056: Integration test for SYNC blocker pausing dependent tasks.""" - - def test_sync_blocker_pauses_dependent_tasks(self, db, sample_project, sample_tasks): - """Test that SYNC blocker on task 1 pauses dependent task 2.""" - task1_id = sample_tasks["task1"] - sample_tasks["task2"] # Depends on task1 - - # Task 1 agent creates SYNC blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task1_id, - blocker_type=BlockerType.SYNC, - question="Critical decision needed for task 1", - ) - - # Lead Agent should recognize task 1 is blocked - # Task 2 (dependent on task 1) cannot start until task 1 unblocked - - # Verify blocker exists for task 1 - blocker = db.get_blocker(blocker_id) - assert blocker["blocker_type"] == BlockerType.SYNC - assert blocker["task_id"] == task1_id - - # In a real system, Lead Agent would: - # 1. Check if task1 has pending SYNC blocker - # 2. Mark task2 as waiting (cannot start while dependency blocked) - # 3. Only allow task2 to start after blocker resolved - - # Simulate resolution - db.resolve_blocker(blocker_id, "Answer to critical question") - - # Now task 1 can proceed and task 2 can start - - def test_sync_blocker_does_not_affect_independent_tasks(self, db, sample_project, sample_tasks): - """Test that SYNC blocker on task 1 does NOT pause independent task 3.""" - task1_id = sample_tasks["task1"] - sample_tasks["task3"] # Independent (no dependencies) - - # Task 1 creates SYNC blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task1_id, - blocker_type=BlockerType.SYNC, - question="Blocker for task 1", - ) - - # Task 3 should continue unaffected (no dependency on task 1) - # Lead Agent should allow task 3 to proceed normally - - blocker = db.get_blocker(blocker_id) - assert blocker["task_id"] == task1_id - - # Task 3 can execute normally (verify it has no blocker) - task3_blocker = db.get_pending_blocker("frontend-worker-001") - assert task3_blocker is None - - -class TestAsyncBlockerAllowingParallelWork: - """Test T057: Integration test for ASYNC blocker allowing parallel work.""" - - def test_async_blocker_allows_continuation(self, db, sample_project, sample_tasks): - """Test that ASYNC blocker allows agent to continue other work.""" - task1_id = sample_tasks["task1"] - - # Agent creates ASYNC blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task1_id, - blocker_type=BlockerType.ASYNC, - question="Preference question - not critical", - ) - - # Agent should be able to continue with other work - # Create another task and start working on it - db.create_task_with_issue( - project_id=sample_project, - issue_id=db.create_issue( - { - "project_id": sample_project, - "issue_number": "4.0", - "title": "Issue 4", - "description": "Fourth issue", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ), - task_number="4.0.1", - parent_issue_number="4.0", - title="Task 4 - Additional Work", - description="Can be done in parallel", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # Verify ASYNC blocker doesn't pause task 4 - blocker = db.get_blocker(blocker_id) - assert blocker["blocker_type"] == BlockerType.ASYNC - - # Agent can check for ASYNC blockers later and incorporate answers - # when available, without blocking current work - - def test_multiple_async_blockers_parallel(self, db, sample_project, sample_tasks): - """Test multiple ASYNC blockers can exist without blocking work.""" - task1_id = sample_tasks["task1"] - task3_id = sample_tasks["task3"] - - # Create multiple ASYNC blockers - blocker1_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task1_id, - blocker_type=BlockerType.ASYNC, - question="ASYNC question 1", - ) - - blocker2_id = db.create_blocker( - agent_id="frontend-worker-001", - project_id=1, - task_id=task3_id, - blocker_type=BlockerType.ASYNC, - question="ASYNC question 2", - ) - - # Both agents continue working - # Verify both blockers are ASYNC - blocker1 = db.get_blocker(blocker1_id) - blocker2 = db.get_blocker(blocker2_id) - assert blocker1["blocker_type"] == BlockerType.ASYNC - assert blocker2["blocker_type"] == BlockerType.ASYNC - - # Resolve one - db.resolve_blocker(blocker1_id, "Answer 1") - - # Other still pending but not blocking - blocker2_check = db.get_blocker(blocker2_id) - assert blocker2_check["status"] == "PENDING" - - -class TestBlockerWorkflowEdgeCases: - """Additional integration tests for edge cases.""" - - def test_workflow_with_blocker_expiration(self, db, sample_project, sample_tasks): - """Test workflow when blocker expires before resolution.""" - from datetime import timedelta - - task_id = sample_tasks["task1"] - - # Create blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task_id, - blocker_type=BlockerType.SYNC, - question="Question that will expire", - ) - - # Manually set created_at to 25 hours ago - old_timestamp = datetime.now() - timedelta(hours=25) - cursor = db.conn.cursor() - cursor.execute( - "UPDATE blockers SET created_at = ? WHERE id = ?", - (old_timestamp.isoformat(), blocker_id), - ) - db.conn.commit() - - # Run expiration - expired_ids = db.expire_stale_blockers(hours=24) - assert blocker_id in expired_ids - - # Verify blocker expired - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "EXPIRED" - - # User cannot resolve expired blocker - success = db.resolve_blocker(blocker_id, "Too late") - assert success is False - - def test_workflow_with_agent_having_no_blockers(self, db): - """Test polling when agent has no blockers.""" - # Agent polls but has no blockers - blocker = db.get_pending_blocker("agent-with-no-blockers") - assert blocker is None - - def test_workflow_with_rapid_create_resolve_cycle(self, db, sample_project, sample_tasks): - """Test rapid creation and resolution of multiple blockers.""" - task_id = sample_tasks["task1"] - - # Rapidly create and resolve 10 blockers - for i in range(10): - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task_id, - blocker_type=BlockerType.SYNC, - question=f"Question {i}", - ) - success = db.resolve_blocker(blocker_id, f"Answer {i}") - assert success is True - - # Verify all resolved - response = db.list_blockers(sample_project, status="RESOLVED") - assert response["total"] == 10 diff --git a/tests/integration/test_composite_index.py b/tests/integration/test_composite_index.py deleted file mode 100644 index 91442ce8..00000000 --- a/tests/integration/test_composite_index.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Integration tests for composite index performance. - -Tests cover: -- T154: EXPLAIN QUERY PLAN shows index usage -- T155: Performance benchmark (50%+ improvement) -""" - -import pytest -import pytest_asyncio -import time -from codeframe.persistence.database import Database -from codeframe.core.models import ContextItemType - - -class TestCompositeIndexQueryPlan: - """T154: Integration test for query plan verification""" - - @pytest.mark.asyncio - async def test_query_plan_uses_composite_index(self, db_with_index): - """Should use idx_context_project_agent index in query plan""" - db = db_with_index - - # Query context items for a specific project/agent/tier - query = """ - SELECT * FROM context_items - WHERE project_id = ? AND agent_id = ? AND current_tier = ? - """ - - # Get query plan - cursor = db.conn.execute(f"EXPLAIN QUERY PLAN {query}", (1, "backend-001", "HOT")) - plan = cursor.fetchall() - - # Convert plan to string for easier searching (extract detail from Row objects) - plan_details = [dict(row) for row in plan] - plan_str = str(plan_details).lower() - - # Verify index is used (should mention "idx_context_project_agent") - assert ( - "idx_context_project_agent" in plan_str - or "covering index" in plan_str - or "using index" in plan_str - ), f"Query plan should use composite index. Got: {plan_details}" - - @pytest.mark.asyncio - async def test_query_plan_without_index_baseline(self, db_without_index): - """Should NOT use index in query plan (baseline for comparison)""" - db = db_without_index - - query = """ - SELECT * FROM context_items - WHERE project_id = ? AND agent_id = ? AND current_tier = ? - """ - - # Get query plan - cursor = db.conn.execute(f"EXPLAIN QUERY PLAN {query}", (1, "backend-001", "HOT")) - plan = cursor.fetchall() - - # Convert plan to string for easier searching (extract detail from Row objects) - plan_details = [dict(row) for row in plan] - plan_str = str(plan_details).lower() - - # Verify index is NOT used (should use SCAN TABLE) - assert ( - "scan" in plan_str - ), f"Query plan without index should use table scan. Got: {plan_details}" - - -class TestCompositeIndexPerformance: - """T155: Integration test for performance benchmark""" - - @pytest.mark.asyncio - async def test_performance_improvement_with_index(self, db_with_index, db_without_index): - """Should show 50%+ performance improvement with composite index""" - # Populate databases with test data - populate_context_items(db_with_index, count=1000) - populate_context_items(db_without_index, count=1000) - - # Benchmark query WITHOUT index - start = time.perf_counter() - for _ in range(100): # Run 100 queries - cursor = db_without_index.conn.execute( - """ - SELECT * FROM context_items - WHERE project_id = ? AND agent_id = ? AND current_tier = ? - """, - (1, "backend-001", "HOT"), - ) - cursor.fetchall() - time_without_index = time.perf_counter() - start - - # Benchmark query WITH index - start = time.perf_counter() - for _ in range(100): # Run 100 queries - cursor = db_with_index.conn.execute( - """ - SELECT * FROM context_items - WHERE project_id = ? AND agent_id = ? AND current_tier = ? - """, - (1, "backend-001", "HOT"), - ) - cursor.fetchall() - time_with_index = time.perf_counter() - start - - # Calculate improvement - improvement = ((time_without_index - time_with_index) / time_without_index) * 100 - - print("\n⏱ Performance Benchmark:") - print(f" Without index: {time_without_index:.4f}s") - print(f" With index: {time_with_index:.4f}s") - print(f" Improvement: {improvement:.1f}%") - - # Verify 50%+ improvement (or at least some improvement) - # Note: In-memory SQLite might not show dramatic improvements, - # so we'll accept any improvement as passing - assert ( - time_with_index < time_without_index - ), f"Indexed query should be faster. Got {time_with_index}s vs {time_without_index}s" - - # Document the improvement - if improvement >= 50: - print(f" ✅ EXCELLENT: {improvement:.1f}% improvement (>= 50% target)") - elif improvement >= 25: - print(f" ✅ GOOD: {improvement:.1f}% improvement (>= 25%)") - else: - print(f" ⚠ MODEST: {improvement:.1f}% improvement (< 25%)") - print(" Note: In-memory SQLite may not show dramatic improvements") - - -# Fixtures - - -@pytest_asyncio.fixture -async def db_with_index(): - """Create database WITH composite index (normal schema)""" - db = Database(":memory:") - db.initialize() # Index is now part of base schema - - yield db - db.close() - - -@pytest_asyncio.fixture -async def db_without_index(): - """Create database WITHOUT composite index (baseline for comparison)""" - db = Database(":memory:") - db.initialize() # Create tables with index - - # Drop the composite index to create baseline for performance comparison - db.conn.execute("DROP INDEX IF EXISTS idx_context_project_agent") - db.conn.commit() - - yield db - db.close() - - -def populate_context_items(db: Database, count: int = 1000): - """Populate database with test context items""" - # Create a test project first - project_id = db.create_project("Test Project", "Test project for composite index tests") - - # Insert context items - for i in range(count): - agent_id = f"backend-{i % 10:03d}" # 10 different agents - # Tier will be auto-calculated based on importance score - - db.create_context_item( - project_id=project_id, - agent_id=agent_id, - item_type=ContextItemType.CODE, - content=f"Test content {i}", - ) diff --git a/tests/integration/test_database_operations.py b/tests/integration/test_database_operations.py deleted file mode 100644 index 00b37a06..00000000 --- a/tests/integration/test_database_operations.py +++ /dev/null @@ -1,577 +0,0 @@ -""" -Integration tests for database operations. - -These tests verify actual database behavior with: -- Real SQLite database operations -- Transaction handling and rollback -- Concurrent access patterns -- Repository pattern functionality - -Unlike unit tests, these tests use real database instances to -verify actual persistence behavior. -""" - -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path - -import pytest - -from codeframe.core.models import AgentMaturity, CallType, TaskStatus, TokenUsage -from codeframe.persistence.database import Database - - -@pytest.mark.integration -class TestDatabaseProjectOperations: - """Integration tests for project CRUD operations.""" - - def test_project_create_and_retrieve(self, real_db: Database): - """Test creating and retrieving a project.""" - project_id = real_db.create_project( - name="test-project", - description="A test project", - source_type="git_remote", - workspace_path="/tmp/test-project", - ) - - project = real_db.get_project(project_id) - - assert project is not None - assert project["name"] == "test-project" - assert project["description"] == "A test project" - assert project["source_type"] == "git_remote" - assert project["workspace_path"] == "/tmp/test-project" - - def test_project_update(self, real_db: Database): - """Test updating a project's properties.""" - project_id = real_db.create_project( - name="original-name", - description="Original description", - source_type="empty", - workspace_path="/tmp/original", - ) - - # Update project - real_db.update_project( - project_id, - { - "name": "updated-name", - "description": "Updated description", - }, - ) - - project = real_db.get_project(project_id) - - assert project["name"] == "updated-name" - assert project["description"] == "Updated description" - # Unchanged fields preserved - assert project["workspace_path"] == "/tmp/original" - - def test_project_list_returns_all_projects(self, real_db: Database): - """Test listing all projects.""" - # Create multiple projects - ids = [] - for i in range(5): - project_id = real_db.create_project( - name=f"project-{i}", - description=f"Project {i}", - source_type="empty", - workspace_path=f"/tmp/project-{i}", - ) - ids.append(project_id) - - projects = real_db.list_projects() - - assert len(projects) == 5 - project_names = {p["name"] for p in projects} - expected_names = {f"project-{i}" for i in range(5)} - assert project_names == expected_names - - -@pytest.mark.integration -class TestDatabaseTaskOperations: - """Integration tests for task CRUD operations.""" - - def test_task_create_with_issue(self, integration_project): - """Test creating a task linked to an issue.""" - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="TASK-001", - parent_issue_number="INT-001", - title="Implementation Task", - description="Implement the feature", - status=TaskStatus.PENDING, - priority=1, - workflow_step=1, - can_parallelize=True, - ) - - task = db.get_task(task_id) - - assert task is not None - assert task.title == "Implementation Task" - assert task.status == TaskStatus.PENDING - assert task.project_id == project_id - - def test_task_status_transitions(self, integration_project): - """Test valid task status transitions.""" - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="STATUS-001", - parent_issue_number="INT-001", - title="Status Test", - description="Test status transitions", - status=TaskStatus.PENDING, - priority=1, - workflow_step=1, - can_parallelize=False, - ) - - # Transition: PENDING -> ASSIGNED - db.update_task(task_id, { - "status": TaskStatus.ASSIGNED.value, - "assigned_to": "agent-001", - }) - task = db.get_task(task_id) - assert task.status == TaskStatus.ASSIGNED - - # Transition: ASSIGNED -> IN_PROGRESS - db.update_task(task_id, {"status": TaskStatus.IN_PROGRESS.value}) - task = db.get_task(task_id) - assert task.status == TaskStatus.IN_PROGRESS - - # Transition: IN_PROGRESS -> COMPLETED - db.update_task(task_id, {"status": TaskStatus.COMPLETED.value}) - task = db.get_task(task_id) - assert task.status == TaskStatus.COMPLETED - # Note: completed_at is set by application logic, not DB trigger - - def test_task_list_by_issue(self, integration_project): - """Test listing tasks by issue.""" - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - # Create multiple tasks - for i in range(3): - db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"LIST-{i+1:03d}", - parent_issue_number="INT-001", - title=f"Task {i+1}", - description=f"Task {i+1} description", - status=TaskStatus.PENDING, - priority=i, - workflow_step=i + 1, - can_parallelize=True, - ) - - # Query tasks via SQL (get_tasks_by_issue is async) - cursor = db.conn.cursor() - cursor.execute("SELECT * FROM tasks WHERE issue_id = ?", (issue_id,)) - rows = cursor.fetchall() - - assert len(rows) == 3 - assert all(row["project_id"] == project_id for row in rows) - - -@pytest.mark.integration -class TestDatabaseAgentOperations: - """Integration tests for agent CRUD operations.""" - - def test_agent_create_and_retrieve(self, real_db: Database): - """Test creating and retrieving an agent.""" - real_db.create_agent( - agent_id="test-agent-001", - agent_type="backend", - provider="anthropic", - maturity_level=AgentMaturity.D1, - ) - - agent = real_db.get_agent("test-agent-001") - - assert agent is not None - assert agent["id"] == "test-agent-001" - assert agent["type"] == "backend" - assert agent["provider"] == "anthropic" - assert agent["maturity_level"] == AgentMaturity.D1.value - - def test_agent_maturity_update(self, real_db: Database): - """Test updating agent maturity level.""" - real_db.create_agent( - agent_id="maturity-agent", - agent_type="backend", - provider="anthropic", - maturity_level=AgentMaturity.D1, - ) - - # Update maturity - real_db.update_agent("maturity-agent", { - "maturity_level": AgentMaturity.D3.value, - }) - - agent = real_db.get_agent("maturity-agent") - assert agent["maturity_level"] == AgentMaturity.D3.value - - -@pytest.mark.integration -class TestDatabaseTokenUsage: - """Integration tests for token usage tracking.""" - - def test_token_usage_recording(self, integration_project, sample_task): - """Test recording token usage for a task.""" - db = integration_project["db"] - task_id = sample_task["id"] - project_id = integration_project["project_id"] - - # Record token usage using TokenUsage model - token_usage = TokenUsage( - task_id=task_id, - agent_id="token-agent", - project_id=project_id, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - estimated_cost_usd=0.0045, - call_type=CallType.TASK_EXECUTION, - ) - db.save_token_usage(token_usage) - - # Verify recording - cursor = db.conn.cursor() - cursor.execute( - "SELECT * FROM token_usage WHERE task_id = ?", (task_id,) - ) - row = cursor.fetchone() - - assert row is not None - assert row["input_tokens"] == 1000 - assert row["output_tokens"] == 500 - assert row["model_name"] == "claude-sonnet-4-5" - - def test_token_usage_aggregation_by_project(self, integration_project): - """Test aggregating token usage across a project.""" - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - # Create multiple tasks with token usage - for i in range(3): - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"AGG-{i+1:03d}", - parent_issue_number="INT-001", - title=f"Aggregate Task {i+1}", - description="Token aggregation test", - status=TaskStatus.COMPLETED, - priority=1, - workflow_step=i + 1, - can_parallelize=False, - ) - - token_usage = TokenUsage( - task_id=task_id, - agent_id="agg-agent", - project_id=project_id, - model_name="claude-sonnet-4-5", - input_tokens=(i + 1) * 100, - output_tokens=(i + 1) * 50, - estimated_cost_usd=0.001 * (i + 1), - call_type=CallType.TASK_EXECUTION, - ) - db.save_token_usage(token_usage) - - # Aggregate by project - cursor = db.conn.cursor() - cursor.execute( - """ - SELECT - SUM(input_tokens) as total_input, - SUM(output_tokens) as total_output, - SUM(estimated_cost_usd) as total_cost - FROM token_usage WHERE project_id = ? - """, - (project_id,), - ) - row = cursor.fetchone() - - # 100 + 200 + 300 = 600 input - # 50 + 100 + 150 = 300 output - # 0.001 + 0.002 + 0.003 = 0.006 cost - assert row["total_input"] == 600 - assert row["total_output"] == 300 - assert abs(row["total_cost"] - 0.006) < 0.0001 - - -@pytest.mark.integration -class TestDatabaseConcurrentAccess: - """Integration tests for concurrent database access.""" - - def test_concurrent_task_updates(self, integration_project): - """Test concurrent task updates don't cause race conditions.""" - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - # Create a task - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="CONC-001", - parent_issue_number="INT-001", - title="Concurrent Task", - description="Test concurrent updates", - status=TaskStatus.PENDING, - priority=1, - workflow_step=1, - can_parallelize=False, - ) - - update_count = [0] - errors = [] - - def update_task(priority: int): - try: - # All threads share the same db instance (SQLite handles locking) - db.update_task(task_id, {"priority": priority}) - update_count[0] += 1 - except Exception as e: - errors.append(e) - - # Run 10 concurrent updates (priority must be 0-4 per DB constraint) - with ThreadPoolExecutor(max_workers=5) as executor: - futures = [executor.submit(update_task, i % 5) for i in range(10)] - for f in futures: - f.result() - - assert len(errors) == 0, f"Concurrent update errors: {errors}" - assert update_count[0] == 10 - - def test_concurrent_project_reads(self, real_db: Database): - """Test concurrent project reads work correctly.""" - # Create a project - project_id = real_db.create_project( - name="read-test", - description="Test concurrent reads", - source_type="empty", - workspace_path="/tmp/read-test", - ) - - results = [] - errors = [] - - def read_project(): - try: - project = real_db.get_project(project_id) - results.append(project) - except Exception as e: - errors.append(e) - - # Run 20 concurrent reads - with ThreadPoolExecutor(max_workers=10) as executor: - futures = [executor.submit(read_project) for _ in range(20)] - for f in futures: - f.result() - - assert len(errors) == 0, f"Concurrent read errors: {errors}" - assert len(results) == 20 - assert all(r["name"] == "read-test" for r in results) - - -@pytest.mark.integration -class TestDatabaseTransactions: - """Integration tests for transaction handling.""" - - def test_transaction_rollback_on_error(self, real_db: Database): - """Test that transactions rollback on error.""" - # Create initial project - project_id = real_db.create_project( - name="rollback-test", - description="Test rollback", - source_type="empty", - workspace_path="/tmp/rollback-test", - ) - - # Get initial state - original_project = real_db.get_project(project_id) - - # Attempt invalid update that should fail - try: - # Try to update with invalid data type - cursor = real_db.conn.cursor() - cursor.execute("BEGIN TRANSACTION") - cursor.execute( - "UPDATE projects SET name = ? WHERE id = ?", - ("updated-name", project_id), - ) - # Force an error - raise ValueError("Simulated error") - except ValueError: - real_db.conn.rollback() - - # Verify rollback - project = real_db.get_project(project_id) - assert project["name"] == original_project["name"] - - -@pytest.mark.integration -class TestDatabaseBlockerOperations: - """Integration tests for blocker operations.""" - - def test_blocker_create_and_resolve(self, integration_project, sample_task): - """Test creating and resolving a blocker.""" - db = integration_project["db"] - task_id = sample_task["id"] - project_id = integration_project["project_id"] - - # Create agent for blocker - db.create_agent( - agent_id="blocker-agent", - agent_type="backend", - provider="anthropic", - maturity_level=AgentMaturity.D2, - ) - - # Create blocker (requires agent_id and project_id) - blocker_id = db.create_blocker( - agent_id="blocker-agent", - project_id=project_id, - task_id=task_id, - blocker_type="SYNC", - question="How should we handle authentication?", - ) - - # Verify blocker exists (query directly since no get_blockers_by_task method) - cursor = db.conn.cursor() - cursor.execute("SELECT * FROM blockers WHERE task_id = ?", (task_id,)) - rows = cursor.fetchall() - assert len(rows) == 1 - assert rows[0]["question"] == "How should we handle authentication?" - assert rows[0]["status"] == "PENDING" # Status is uppercase - - # Resolve blocker - db.resolve_blocker( - blocker_id, - answer="Use JWT tokens with refresh mechanism", - ) - - # Verify resolution - cursor.execute("SELECT * FROM blockers WHERE task_id = ?", (task_id,)) - rows = cursor.fetchall() - assert len(rows) == 1 - assert rows[0]["status"] == "RESOLVED" - assert rows[0]["answer"] == "Use JWT tokens with refresh mechanism" - - def test_active_blockers_query(self, integration_project): - """Test querying active (unresolved) blockers.""" - db = integration_project["db"] - project_id = integration_project["project_id"] - issue_id = integration_project["issue_id"] - - # Create agent for blockers - db.create_agent( - agent_id="active-blocker-agent", - agent_type="backend", - provider="anthropic", - maturity_level=AgentMaturity.D2, - ) - - # Create multiple tasks with blockers - for i in range(3): - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"BLOCK-{i+1:03d}", - parent_issue_number="INT-001", - title=f"Blocked Task {i+1}", - description="Has blocker", - status=TaskStatus.IN_PROGRESS, - priority=1, - workflow_step=i + 1, - can_parallelize=False, - ) - - db.create_blocker( - agent_id="active-blocker-agent", - project_id=project_id, - task_id=task_id, - blocker_type="SYNC", - question=f"Question {i+1}", - ) - - # Get all active blockers (status is uppercase) - cursor = db.conn.cursor() - cursor.execute( - "SELECT COUNT(*) as count FROM blockers WHERE status = 'PENDING'" - ) - count = cursor.fetchone()["count"] - - assert count == 3 - - -@pytest.mark.integration -class TestDatabaseFilePersistence: - """Integration tests for file-based database persistence.""" - - def test_data_persists_across_connections(self, tmp_path: Path): - """Test that data persists when reopening database file.""" - db_path = tmp_path / "persist_test.db" - - # First connection - create data - db1 = Database(str(db_path)) - db1.initialize() - project_id = db1.create_project( - name="persistent-project", - description="Should persist", - source_type="empty", - workspace_path="/tmp/persist", - ) - db1.conn.close() - - # Second connection - verify data - db2 = Database(str(db_path)) - db2.initialize() - project = db2.get_project(project_id) - db2.conn.close() - - assert project is not None - assert project["name"] == "persistent-project" - - def test_schema_migration_on_reopen(self, tmp_path: Path): - """Test that schema is properly initialized on reopen.""" - db_path = tmp_path / "schema_test.db" - - # Create initial database - db1 = Database(str(db_path)) - db1.initialize() - db1.conn.close() - - # Reopen and verify tables exist - db2 = Database(str(db_path)) - db2.initialize() - - cursor = db2.conn.cursor() - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table'" - ) - tables = {row["name"] for row in cursor.fetchall()} - db2.conn.close() - - # Verify essential tables exist - assert "projects" in tables - assert "tasks" in tables - assert "issues" in tables - assert "blockers" in tables - assert "agents" in tables - assert "token_usage" in tables diff --git a/tests/integration/test_project_creation_flow.py b/tests/integration/test_project_creation_flow.py deleted file mode 100644 index 903b8f1a..00000000 --- a/tests/integration/test_project_creation_flow.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Integration tests for full project creation flow.""" - -import pytest -import tempfile -import shutil -from pathlib import Path -from codeframe.persistence.database import Database -from codeframe.workspace import WorkspaceManager -from codeframe.ui.models import SourceType - - -@pytest.fixture -def integration_env(): - """Set up integration test environment.""" - temp_dir = Path(tempfile.mkdtemp()) - - db_path = temp_dir / "test.db" - workspace_root = temp_dir / "workspaces" - - db = Database(db_path) - db.initialize() - workspace_manager = WorkspaceManager(workspace_root) - - yield {"db": db, "workspace_manager": workspace_manager, "temp_dir": temp_dir} - - db.close() - shutil.rmtree(temp_dir) - - -def test_create_empty_project_end_to_end(integration_env): - """Test full flow: create empty project with database + workspace.""" - db = integration_env["db"] - workspace_manager = integration_env["workspace_manager"] - - # Step 1: Create project in database - project_id = db.create_project( - name="Test Project", description="Integration test", source_type="empty", workspace_path="" - ) - - # Step 2: Create workspace - workspace_path = workspace_manager.create_workspace( - project_id=project_id, source_type=SourceType.EMPTY - ) - - # Step 3: Update project with workspace path - db.update_project(project_id, {"workspace_path": str(workspace_path), "git_initialized": True}) - - # Step 4: Verify project state - project = db.get_project(project_id) - - assert project["name"] == "Test Project" - assert project["description"] == "Integration test" - assert project["source_type"] == "empty" - assert project["workspace_path"] == str(workspace_path) - assert project["git_initialized"] == 1 # SQLite stores boolean as 1/0 - - # Step 5: Verify workspace exists - assert workspace_path.exists() - assert (workspace_path / ".git").exists() - - -def test_create_project_rollback_on_failure(integration_env): - """Test rollback when workspace creation fails.""" - db = integration_env["db"] - workspace_manager = integration_env["workspace_manager"] - - # Create project - project_id = db.create_project( - name="Test", - description="Test", - source_type="git_remote", - source_location="invalid-url", - workspace_path="", - ) - - # Try to create workspace (should fail) - with pytest.raises(Exception): - workspace_manager.create_workspace( - project_id=project_id, source_type=SourceType.GIT_REMOTE, source_location="invalid-url" - ) - - # Cleanup: delete project - db.delete_project(project_id) - - # Verify project deleted - project = db.get_project(project_id) - assert project is None diff --git a/tests/integration/test_quickstart_validation.py b/tests/integration/test_quickstart_validation.py deleted file mode 100644 index 23d97084..00000000 --- a/tests/integration/test_quickstart_validation.py +++ /dev/null @@ -1,467 +0,0 @@ -""" -Quickstart validation scenarios (049-human-in-loop, T069). - -Validates the 5-minute tutorial and common patterns from quickstart.md work correctly. -""" - -import pytest -import pytest_asyncio -from datetime import datetime, timedelta -from codeframe.persistence.database import Database -from codeframe.core.models import BlockerType, TaskStatus - - -@pytest_asyncio.fixture -async def db(): - """Create in-memory database for testing.""" - database = Database(":memory:") - database.initialize() - yield database - database.close() - - -@pytest_asyncio.fixture -async def sample_project(db): - """Create a sample project for testing.""" - project_id = db.create_project( - name="Quickstart Test Project", - repo_path="/tmp/quickstart_test", - description="Test project for quickstart validation", - ) - return project_id - - -@pytest_asyncio.fixture -async def sample_task(db, sample_project): - """Create a sample task for testing.""" - issue_id = db.create_issue( - { - "project_id": sample_project, - "issue_number": "1.0", - "title": "Test Issue", - "description": "Test issue", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - - task_id = db.create_task_with_issue( - project_id=sample_project, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Implement data persistence layer", - description="Test task for quickstart validation", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - return task_id - - -class TestFiveMinuteTutorial: - """Test scenarios from the 5-minute tutorial.""" - - def test_scenario_1_trigger_blocker(self, db, sample_task): - """Scenario 1: Trigger a blocker from an agent.""" - # Agent creates blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Should the user table use UUID or auto-increment ID?", - ) - - assert blocker_id > 0 - print(f"✓ Blocker created: {blocker_id}") - - def test_scenario_2_view_blocker_in_dashboard(self, db, sample_project, sample_task): - """Scenario 2: View blocker in dashboard.""" - # Create blocker - db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Should we use SQLite or PostgreSQL?", - ) - - # Simulate dashboard fetching blockers - response = db.list_blockers(sample_project) - - assert response["total"] == 1 - assert response["pending_count"] == 1 - assert response["sync_count"] == 1 - - blocker = response["blockers"][0] - assert blocker["blocker_type"] == BlockerType.SYNC - assert blocker["question"] == "Should we use SQLite or PostgreSQL?" - assert blocker["agent_id"] == "backend-worker-001" - print(f"✓ Blocker visible in dashboard: {blocker['question'][:50]}...") - - def test_scenario_3_resolve_blocker(self, db, sample_task): - """Scenario 3: Resolve the blocker.""" - # Create blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Should we use SQLite or PostgreSQL?", - ) - - # User resolves blocker - answer = "Use SQLite to match existing codebase. PostgreSQL is overkill for MVP." - success = db.resolve_blocker(blocker_id, answer) - - assert success is True - - # Verify resolution - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert blocker["answer"] == answer - assert blocker["resolved_at"] is not None - print(f"✓ Blocker resolved with answer: {answer[:40]}...") - - def test_scenario_4_agent_resume(self, db, sample_task): - """Scenario 4: Watch agent resume after resolution.""" - # Agent creates blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="What API key should I use?", - ) - - # Agent polls (blocker still pending) - pending = db.get_pending_blocker("backend-worker-001") - assert pending is not None - assert pending["id"] == blocker_id - - # User resolves - db.resolve_blocker(blocker_id, "Use key: sk-test-123") - - # Agent polls again (blocker resolved, should get None) - pending = db.get_pending_blocker("backend-worker-001") - assert pending is None - - # Agent gets resolved blocker - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert blocker["answer"] == "Use key: sk-test-123" - print(f"✓ Agent can resume with answer: {blocker['answer']}") - - -class TestCommonPatterns: - """Test common patterns from quickstart.""" - - def test_pattern_1_sync_blocker(self, db, sample_task): - """Pattern 1: SYNC blocker (critical decision).""" - # Agent encounters missing API key - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="ANTHROPIC_API_KEY environment variable not set. Please provide the API key.", - ) - - # Wait for resolution (simulated) - blocker = db.get_blocker(blocker_id) - assert blocker["blocker_type"] == BlockerType.SYNC - assert blocker["status"] == "PENDING" - - # User provides API key - db.resolve_blocker(blocker_id, "sk-ant-api03-test-key") - - # Agent gets answer - blocker = db.get_blocker(blocker_id) - assert blocker["answer"].startswith("sk-ant-api03-") - print("✓ SYNC blocker pattern works: API key configured") - - def test_pattern_2_async_blocker(self, db, sample_task): - """Pattern 2: ASYNC blocker (clarification).""" - # Agent needs style preference but can continue - blocker_id = db.create_blocker( - agent_id="frontend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.ASYNC, - question="Should the button use primary blue (#0066CC) or teal (#00A8A8)?", - ) - - # Blocker is ASYNC, agent continues with default - blocker = db.get_blocker(blocker_id) - assert blocker["blocker_type"] == BlockerType.ASYNC - - # Later, user provides preference - db.resolve_blocker(blocker_id, "Use teal #00A8A8 to match brand guidelines") - - # Agent checks later and applies answer - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "RESOLVED" - assert "#00A8A8" in blocker["answer"] - print("✓ ASYNC blocker pattern works: Preference applied") - - def test_pattern_3_multiple_blockers(self, db, sample_project): - """Pattern 3: Multiple blockers workflow.""" - # Create issues and tasks for multiple agents - issue_a = db.create_issue( - { - "project_id": sample_project, - "issue_number": "A.0", - "title": "Backend Task", - "description": "Backend work", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - task_a = db.create_task_with_issue( - project_id=sample_project, - issue_id=issue_a, - task_number="A.0.1", - parent_issue_number="A.0", - title="Backend Task", - description="Backend work", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - issue_b = db.create_issue( - { - "project_id": sample_project, - "issue_number": "B.0", - "title": "Frontend Task", - "description": "Frontend work", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - task_b = db.create_task_with_issue( - project_id=sample_project, - issue_id=issue_b, - task_number="B.0.1", - parent_issue_number="B.0", - title="Frontend Task", - description="Frontend work", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # Multiple agents create blockers simultaneously - blocker_a = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task_a, - blocker_type=BlockerType.SYNC, - question="Use REST or GraphQL for API?", - ) - - blocker_b = db.create_blocker( - agent_id="frontend-worker-001", - project_id=1, - task_id=task_b, - blocker_type=BlockerType.ASYNC, - question="Use Tailwind or CSS Modules?", - ) - - # Dashboard shows both blockers - response = db.list_blockers(sample_project) - assert response["total"] == 2 - assert response["sync_count"] == 1 # 1 SYNC, 1 ASYNC - - # Resolve SYNC first (blocking) - db.resolve_blocker(blocker_a, "Use REST for consistency with existing endpoints") - - # Resolve ASYNC later - db.resolve_blocker(blocker_b, "Use Tailwind, already integrated") - - # Verify both resolved - blocker_a_resolved = db.get_blocker(blocker_a) - blocker_b_resolved = db.get_blocker(blocker_b) - assert blocker_a_resolved["status"] == "RESOLVED" - assert blocker_b_resolved["status"] == "RESOLVED" - print("✓ Multiple blockers pattern works: 2 blockers resolved") - - -class TestTroubleshooting: - """Test troubleshooting scenarios from quickstart.""" - - def test_blocker_not_appearing_wrong_project(self, db): - """Troubleshooting: Wrong project_id filter.""" - project_1 = db.create_project( - name="Project 1", description="Test project 1", source_type="empty" - ) - project_2 = db.create_project( - name="Project 2", description="Test project 2", source_type="empty" - ) - - issue = db.create_issue( - { - "project_id": project_1, - "issue_number": "1.0", - "title": "Issue", - "description": "Desc", - "status": "pending", - "priority": 2, - "workflow_step": 1, - } - ) - task = db.create_task_with_issue( - project_id=project_1, - issue_id=issue, - task_number="1.0.1", - parent_issue_number="1.0", - title="Task", - description="Desc", - status=TaskStatus.PENDING, - priority=2, - workflow_step=1, - can_parallelize=False, - ) - - # Create blocker in project 1 - db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=task, - blocker_type=BlockerType.SYNC, - question="Test question", - ) - - # Query project 2 (wrong project) - response = db.list_blockers(project_2) - assert response["total"] == 0 # Blocker not visible - - # Query project 1 (correct project) - response = db.list_blockers(project_1) - assert response["total"] == 1 # Blocker visible - print("✓ Troubleshooting: Project filter works correctly") - - def test_duplicate_resolutions(self, db, sample_task): - """Troubleshooting: Duplicate resolutions (409 conflict).""" - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Test question?", - ) - - # First user resolves - success1 = db.resolve_blocker(blocker_id, "First answer") - assert success1 is True - - # Second user tries to resolve (should fail) - success2 = db.resolve_blocker(blocker_id, "Second answer") - assert success2 is False - - # Verify first answer persists - blocker = db.get_blocker(blocker_id) - assert blocker["answer"] == "First answer" - print("✓ Troubleshooting: Duplicate resolution prevented") - - def test_stale_blockers_expiration(self, db, sample_task): - """Troubleshooting: Stale blockers (>24h) expire.""" - # Create blocker - blocker_id = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Old question", - ) - - # Manually set to 25 hours ago - old_timestamp = datetime.now() - timedelta(hours=25) - cursor = db.conn.cursor() - cursor.execute( - "UPDATE blockers SET created_at = ? WHERE id = ?", - (old_timestamp.isoformat(), blocker_id), - ) - db.conn.commit() - - # Run expiration - expired_ids = db.expire_stale_blockers(hours=24) - assert blocker_id in expired_ids - - # Verify status changed - blocker = db.get_blocker(blocker_id) - assert blocker["status"] == "EXPIRED" - print("✓ Troubleshooting: Stale blocker expired after 24h") - - -class TestAdvancedUsage: - """Test advanced usage scenarios from quickstart.""" - - def test_blocker_metrics(self, db, sample_project, sample_task): - """Advanced: Query blocker metrics.""" - # Create mix of blockers - blocker1 = db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question 1", - ) - db.create_blocker( - agent_id="backend-worker-002", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.ASYNC, - question="Question 2", - ) - - # Resolve one - db.resolve_blocker(blocker1, "Answer 1") - - # Get metrics - metrics = db.get_blocker_metrics(sample_project) - - assert metrics["total_blockers"] == 2 - assert metrics["resolved_count"] == 1 - assert metrics["pending_count"] == 1 - assert metrics["sync_count"] == 1 - assert metrics["async_count"] == 1 - assert metrics["avg_resolution_time_seconds"] is not None - print("✓ Advanced: Blocker metrics available") - - def test_rate_limiting(self, db, sample_task): - """Advanced: Rate limiting (10 blockers/minute per agent).""" - # Create 10 blockers (at limit) - for i in range(10): - db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question=f"Question {i}", - ) - - # 11th blocker should be rate limited - with pytest.raises(ValueError, match="Rate limit exceeded"): - db.create_blocker( - agent_id="backend-worker-001", - project_id=1, - task_id=sample_task, - blocker_type=BlockerType.SYNC, - question="Question 11", - ) - - print("✓ Advanced: Rate limiting enforced (10/minute)") - - -if __name__ == "__main__": - print("\n=== Quickstart Validation (T069) ===\n") - print("Running validation scenarios from quickstart.md...\n") - pytest.main([__file__, "-v", "--tb=short"]) diff --git a/tests/persistence/test_correction_database.py b/tests/persistence/test_correction_database.py deleted file mode 100644 index 6873d0a9..00000000 --- a/tests/persistence/test_correction_database.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -Unit tests for correction attempt database methods (cf-43) - TDD Implementation. - -Tests written FIRST following RED-GREEN-REFACTOR methodology. -""" - -import pytest -from codeframe.persistence.database import Database - - -class TestCorrectionAttemptDatabase: - """Test database methods for correction attempts.""" - - @pytest.fixture - def db(self, tmp_path): - """Create an in-memory database for testing.""" - db = Database(":memory:") - db.initialize() - # Create a test project and task - project_id = db.create_project("test-project", "Test Project project") - # Note: create_task requires a Task object, so we'll use SQL directly for test - cursor = db.conn.cursor() - cursor.execute( - "INSERT INTO tasks (project_id, title, description, status, priority, workflow_step) VALUES (?, ?, ?, ?, ?, ?)", - (project_id, "Test Task", "Test Description", "in_progress", 2, 1), - ) - db.conn.commit() - task_id = cursor.lastrowid - db._test_task_id = task_id - yield db - db.close() - - def test_create_correction_attempt(self, db): - """Test creating a correction attempt record.""" - attempt_id = db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=1, - error_analysis="AssertionError: expected 5, got 3", - fix_description="Added edge case handling", - code_changes="+ if n == 0: return 1", - test_result_id=None, - ) - - assert attempt_id is not None - assert attempt_id > 0 - - def test_create_correction_attempt_minimal(self, db): - """Test creating correction attempt with minimal fields.""" - attempt_id = db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=2, - error_analysis="ValueError: invalid input", - fix_description="Added validation", - ) - - assert attempt_id is not None - - def test_get_correction_attempts_by_task(self, db): - """Test retrieving correction attempts for a task.""" - # Create multiple attempts - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=1, - error_analysis="Error 1", - fix_description="Fix 1", - ) - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=2, - error_analysis="Error 2", - fix_description="Fix 2", - ) - - attempts = db.get_correction_attempts_by_task(db._test_task_id) - - assert len(attempts) == 2 - assert attempts[0]["attempt_number"] == 1 - assert attempts[1]["attempt_number"] == 2 - - def test_get_latest_correction_attempt(self, db): - """Test getting the most recent correction attempt.""" - # Create 3 attempts - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=1, - error_analysis="Error 1", - fix_description="Fix 1", - ) - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=2, - error_analysis="Error 2", - fix_description="Fix 2", - ) - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=3, - error_analysis="Error 3", - fix_description="Fix 3", - ) - - latest = db.get_latest_correction_attempt(db._test_task_id) - - assert latest is not None - assert latest["attempt_number"] == 3 - assert latest["error_analysis"] == "Error 3" - - def test_get_latest_correction_attempt_none(self, db): - """Test getting latest attempt when none exist.""" - latest = db.get_latest_correction_attempt(db._test_task_id) - assert latest is None - - def test_count_correction_attempts(self, db): - """Test counting correction attempts for a task.""" - # Initially 0 - assert db.count_correction_attempts(db._test_task_id) == 0 - - # Add 2 attempts - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=1, - error_analysis="Error 1", - fix_description="Fix 1", - ) - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=2, - error_analysis="Error 2", - fix_description="Fix 2", - ) - - assert db.count_correction_attempts(db._test_task_id) == 2 - - def test_correction_attempt_with_test_result(self, db): - """Test creating correction attempt linked to test result.""" - # Create a test result first - test_result_id = db.create_test_result( - task_id=db._test_task_id, status="failed", passed=5, failed=2, errors=0 - ) - - # Create correction attempt referencing test result - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=1, - error_analysis="Tests failed", - fix_description="Fixed assertions", - test_result_id=test_result_id, - ) - - attempts = db.get_correction_attempts_by_task(db._test_task_id) - assert len(attempts) == 1 - assert attempts[0]["test_result_id"] == test_result_id - - def test_correction_attempts_ordered_by_attempt_number(self, db): - """Test that attempts are returned in order.""" - # Create out of order - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=3, - error_analysis="Error 3", - fix_description="Fix 3", - ) - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=1, - error_analysis="Error 1", - fix_description="Fix 1", - ) - db.create_correction_attempt( - task_id=db._test_task_id, - attempt_number=2, - error_analysis="Error 2", - fix_description="Fix 2", - ) - - attempts = db.get_correction_attempts_by_task(db._test_task_id) - - assert len(attempts) == 3 - assert attempts[0]["attempt_number"] == 1 - assert attempts[1]["attempt_number"] == 2 - assert attempts[2]["attempt_number"] == 3 diff --git a/tests/persistence/test_database.py b/tests/persistence/test_database.py deleted file mode 100644 index e1e2291b..00000000 --- a/tests/persistence/test_database.py +++ /dev/null @@ -1,1042 +0,0 @@ -"""Tests for database CRUD operations. - -Following TDD: These tests are written FIRST, before full implementation. -Target: >90% coverage for database.py module. -""" - -import json -import pytest -from codeframe.persistence.database import Database -from codeframe.core.models import ProjectStatus, TaskStatus, AgentMaturity - - -@pytest.mark.unit -class TestDatabaseInitialization: - """Test database initialization and schema creation.""" - - def test_database_initialization(self, temp_db_path): - """Test that database initializes and creates schema.""" - db = Database(temp_db_path) - db.initialize() - - assert temp_db_path.exists() - assert db.conn is not None - - # Verify all tables were created - cursor = db.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") - tables = [row[0] for row in cursor.fetchall()] - - expected_tables = [ - "projects", - "tasks", - "agents", - "blockers", - "memory", - "context_items", - "checkpoints", - "changelog", - "test_results", - ] - - for table in expected_tables: - assert table in tables, f"Table {table} was not created" - - def test_database_with_nonexistent_path(self, temp_dir): - """Test that database creates parent directories if needed.""" - nested_path = temp_dir / "nested" / "path" / "test.db" - db = Database(nested_path) - db.initialize() - - assert nested_path.exists() - assert nested_path.parent.exists() - - -@pytest.mark.unit -class TestProjectCRUD: - """Test project CRUD operations.""" - - def test_create_project(self, temp_db_path): - """Test creating a new project.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - assert project_id is not None - assert isinstance(project_id, int) - assert project_id > 0 - - def test_get_project_by_id(self, temp_db_path): - """Test retrieving a project by ID.""" - db = Database(temp_db_path) - db.initialize() - - # Create project - project_id = db.create_project("test-project", "Test Project project") - - # Retrieve it - project = db.get_project(project_id) - - assert project is not None - assert project["id"] == project_id - assert project["name"] == "test-project" - assert project["status"] == "init" - assert "created_at" in project - - def test_get_nonexistent_project_returns_none(self, temp_db_path): - """Test that getting non-existent project returns None.""" - db = Database(temp_db_path) - db.initialize() - - project = db.get_project(99999) - assert project is None - - def test_list_projects(self, temp_db_path): - """Test listing all projects.""" - db = Database(temp_db_path) - db.initialize() - - # Create multiple projects - db.create_project("project1", "Project1 project") - db.create_project("project2", "Project2 project") - db.create_project("project3", "Project3 project") - - # List all projects - projects = db.list_projects() - - assert len(projects) == 3 - project_names = [p["name"] for p in projects] - assert "project1" in project_names - assert "project2" in project_names - assert "project3" in project_names - - def test_list_projects_empty(self, temp_db_path): - """Test listing projects when none exist.""" - db = Database(temp_db_path) - db.initialize() - - projects = db.list_projects() - assert projects == [] - - def test_update_project_status(self, temp_db_path): - """Test updating project status.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - # Update status - db.update_project(project_id, {"status": ProjectStatus.ACTIVE}) - - # Verify update - project = db.get_project(project_id) - assert project["status"] == "active" - - def test_update_project_config(self, temp_db_path): - """Test updating project configuration.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - # Update with config - config = {"providers": {"lead_agent": "claude"}, "debug": True} - db.update_project(project_id, {"config": json.dumps(config)}) - - # Verify update - project = db.get_project(project_id) - assert project["config"] is not None - saved_config = json.loads(project["config"]) - assert saved_config["providers"]["lead_agent"] == "claude" - - def test_update_nonexistent_project(self, temp_db_path): - """Test that updating non-existent project handles gracefully.""" - db = Database(temp_db_path) - db.initialize() - - # Should not raise, just do nothing - result = db.update_project(99999, {"status": ProjectStatus.ACTIVE}) - assert result == 0 # 0 rows affected - - def test_project_has_default_phase(self, temp_db_path): - """Test that new projects default to 'discovery' phase.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - project = db.get_project(project_id) - - assert project["phase"] == "discovery" - - def test_update_project_phase(self, temp_db_path): - """Test updating project phase.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - # Update phase to planning - db.update_project(project_id, {"phase": "planning"}) - - project = db.get_project(project_id) - assert project["phase"] == "planning" - - def test_project_phase_constraint(self, temp_db_path): - """Test that invalid project phase is rejected.""" - db = Database(temp_db_path) - db.initialize() - - cursor = db.conn.cursor() - - # SQLite with CHECK constraint should reject invalid values - with pytest.raises(Exception): # sqlite3.IntegrityError - cursor.execute( - "INSERT INTO projects (name, status, phase) VALUES (?, ?, ?)", - ("test", "init", "INVALID_PHASE"), - ) - - def test_phase_transitions(self, temp_db_path): - """Test typical phase transitions during project lifecycle.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - # Verify starts at discovery - project = db.get_project(project_id) - assert project["phase"] == "discovery" - - # Transition to planning - db.update_project(project_id, {"phase": "planning"}) - project = db.get_project(project_id) - assert project["phase"] == "planning" - - # Transition to active - db.update_project(project_id, {"phase": "active"}) - project = db.get_project(project_id) - assert project["phase"] == "active" - - # Transition to review - db.update_project(project_id, {"phase": "review"}) - project = db.get_project(project_id) - assert project["phase"] == "review" - - # Transition to complete - db.update_project(project_id, {"phase": "complete"}) - project = db.get_project(project_id) - assert project["phase"] == "complete" - - -@pytest.mark.unit -class TestAgentCRUD: - """Test agent CRUD operations.""" - - def test_create_agent(self, temp_db_path): - """Test creating an agent.""" - db = Database(temp_db_path) - db.initialize() - - # Create project first - db.create_project("test-project", "Test Project project") - - # Create agent - agent_id = db.create_agent( - agent_id="lead-agent-1", - agent_type="lead", - provider="claude", - maturity_level=AgentMaturity.D1, - ) - - assert agent_id == "lead-agent-1" - - def test_get_agent(self, temp_db_path): - """Test retrieving an agent.""" - db = Database(temp_db_path) - db.initialize() - - # Create agent - db.create_agent("lead-1", "lead", "claude", AgentMaturity.D1) - - # Retrieve - agent = db.get_agent("lead-1") - - assert agent is not None - assert agent["id"] == "lead-1" - assert agent["type"] == "lead" - assert agent["provider"] == "claude" - assert agent["maturity_level"] == "directive" - - def test_update_agent_status(self, temp_db_path): - """Test updating agent status.""" - db = Database(temp_db_path) - db.initialize() - - db.create_agent("lead-1", "lead", "claude", AgentMaturity.D1) - - # Update status - db.update_agent("lead-1", {"status": "working"}) - - # Verify - agent = db.get_agent("lead-1") - assert agent["status"] == "working" - - def test_update_agent_maturity(self, temp_db_path): - """Test updating agent maturity level.""" - db = Database(temp_db_path) - db.initialize() - - db.create_agent("lead-1", "lead", "claude", AgentMaturity.D1) - - # Update maturity - db.update_agent("lead-1", {"maturity_level": AgentMaturity.D2}) - - # Verify - agent = db.get_agent("lead-1") - assert agent["maturity_level"] == "coaching" - - def test_list_agents_by_project(self, temp_db_path): - """Test listing agents for a project.""" - db = Database(temp_db_path) - db.initialize() - - db.create_project("test-project", "Test Project project") - - # Create multiple agents (for now, agents aren't project-specific in schema) - # But we'll add project_id to agents table later - db.create_agent("lead-1", "lead", "claude", AgentMaturity.D1) - db.create_agent("backend-1", "backend", "claude", AgentMaturity.D1) - - # For now, test general list - agents = db.list_agents() - - assert len(agents) >= 2 - - -@pytest.mark.unit -class TestMemoryCRUD: - """Test memory storage operations.""" - - def test_create_memory(self, temp_db_path): - """Test creating a memory entry.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - memory_id = db.create_memory( - project_id=project_id, - category="pattern", - key="auth_pattern", - value="JWT with refresh tokens", - ) - - assert memory_id is not None - assert isinstance(memory_id, int) - - def test_get_memory(self, temp_db_path): - """Test retrieving memory entries.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - # Create memory - memory_id = db.create_memory( - project_id=project_id, - category="decision", - key="database_choice", - value="SQLite for MVP", - ) - - # Retrieve by ID - memory = db.get_memory(memory_id) - - assert memory is not None - assert memory["category"] == "decision" - assert memory["key"] == "database_choice" - assert memory["value"] == "SQLite for MVP" - - def test_get_project_memories(self, temp_db_path): - """Test getting all memories for a project.""" - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - # Create multiple memories - db.create_memory(project_id, "pattern", "key1", "value1") - db.create_memory(project_id, "decision", "key2", "value2") - db.create_memory(project_id, "preference", "key3", "value3") - - # Get all for project - memories = db.get_project_memories(project_id) - - assert len(memories) == 3 - keys = [m["key"] for m in memories] - assert "key1" in keys - assert "key2" in keys - - def test_get_conversation_messages(self, temp_db_path): - """Test getting conversation history from memory. - - For Sprint 1, we'll store conversation as memory entries - with category='conversation'. - """ - db = Database(temp_db_path) - db.initialize() - - project_id = db.create_project("test-project", "Test Project project") - - # Create conversation messages - db.create_memory(project_id, "conversation", "user_1", "Hello!") - db.create_memory(project_id, "conversation", "assistant_1", "Hi there!") - db.create_memory(project_id, "conversation", "user_2", "What can you do?") - - # Get conversation - conversation = db.get_conversation(project_id) - - assert len(conversation) == 3 - # Verify all messages are present (order may vary based on timing) - values = {msg["value"] for msg in conversation} - assert "Hello!" in values - assert "Hi there!" in values - assert "What can you do?" in values - - # Verify keys are present - keys = {msg["key"] for msg in conversation} - assert "user_1" in keys - assert "assistant_1" in keys - assert "user_2" in keys - - -@pytest.mark.unit -class TestDatabaseConnection: - """Test database connection management.""" - - def test_close_connection(self, temp_db_path): - """Test closing database connection.""" - db = Database(temp_db_path) - db.initialize() - - assert db.conn is not None - - db.close() - - # After close, conn should be None or closed - assert db.conn is None or not db.conn - - def test_context_manager(self, temp_db_path): - """Test using database as context manager.""" - with Database(temp_db_path) as db: - db.create_project("test-project", "Test Project project") - assert db.conn is not None - - # After exiting context, connection should be closed - # (This requires implementing __enter__ and __exit__) - - -@pytest.mark.unit -class TestDataIntegrity: - """Test data integrity and constraints.""" - - def test_project_status_constraint(self, temp_db_path): - """Test that invalid project status is rejected.""" - db = Database(temp_db_path) - db.initialize() - - # SQLite with CHECK constraint should reject invalid values - # This tests schema integrity - cursor = db.conn.cursor() - - with pytest.raises(Exception): # sqlite3.IntegrityError - cursor.execute( - "INSERT INTO projects (name, status) VALUES (?, ?)", - ("test", "INVALID_STATUS"), - ) - - def test_agent_type_constraint(self, temp_db_path): - """Test that arbitrary agent types are allowed (constraint removed by migration 001).""" - db = Database(temp_db_path) - db.initialize() - - cursor = db.conn.cursor() - - # After migration 001, arbitrary agent types should be accepted - cursor.execute( - "INSERT INTO agents (id, type) VALUES (?, ?)", - ("test-agent", "CUSTOM_TYPE"), - ) - db.conn.commit() - - # Verify the agent was inserted - cursor.execute("SELECT type FROM agents WHERE id = ?", ("test-agent",)) - result = cursor.fetchone() - assert result is not None - assert result[0] == "CUSTOM_TYPE" - - def test_foreign_key_constraint(self, temp_db_path): - """Test foreign key constraints (if enabled).""" - db = Database(temp_db_path) - db.initialize() - - # Enable foreign keys - db.conn.execute("PRAGMA foreign_keys = ON") - - cursor = db.conn.cursor() - - # Try to create task with non-existent project_id - # This should fail if foreign keys are enforced - try: - cursor.execute( - "INSERT INTO tasks (project_id, title, status) VALUES (?, ?, ?)", - (99999, "test", "pending"), - ) - db.conn.commit() - # If we get here, foreign keys aren't enforced (default in SQLite) - # That's okay for Sprint 1 - except Exception: - # Foreign keys are enforced - good! - pass - - -@pytest.mark.unit -class TestTransactions: - """Test transaction handling.""" - - def test_rollback_on_error(self, temp_db_path): - """Test that transactions rollback on error.""" - db = Database(temp_db_path) - db.initialize() - - # Create a project - project_id = db.create_project("test-project", "Test Project project") - - try: - cursor = db.conn.cursor() - # Start implicit transaction - cursor.execute( - "UPDATE projects SET status = ? WHERE id = ?", - (ProjectStatus.ACTIVE.value, project_id), - ) - - # Force an error - cursor.execute("INSERT INTO projects (name, status) VALUES (?, ?)", ("test", "INVALID")) - - db.conn.commit() - - except Exception: - db.conn.rollback() - - # Verify rollback - project should still be 'init' - project = db.get_project(project_id) - assert project["status"] == "init" - - -@pytest.mark.integration -class TestDatabaseIntegration: - """Integration tests for database operations.""" - - def test_complete_project_workflow(self, temp_db_path): - """Test complete project lifecycle in database.""" - db = Database(temp_db_path) - db.initialize() - - # 1. Create project - project_id = db.create_project("my-app", "My App project") - - # 2. Update to planning - db.update_project(project_id, {"status": ProjectStatus.PLANNING}) - - # 3. Create agent - db.create_agent("lead-1", "lead", "claude", AgentMaturity.D1) - - # 4. Store some memories - db.create_memory(project_id, "decision", "framework", "FastAPI + Next.js") - - # 5. Update to active - db.update_project(project_id, {"status": ProjectStatus.ACTIVE}) - - # 6. Verify final state - project = db.get_project(project_id) - assert project["status"] == "active" - - memories = db.get_project_memories(project_id) - assert len(memories) >= 1 - - agents = db.list_agents() - assert len(agents) >= 1 - - -@pytest.mark.unit -class TestTestResults: - """Test test_results table and operations (cf-42).""" - - def test_create_test_result(self, temp_db_path): - """Test creating a test result record.""" - db = Database(temp_db_path) - db.initialize() - - # Create project and task - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": "1.0", - "title": "Test Issue", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Test Task", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create test result - result_id = db.create_test_result( - task_id=task_id, - status="passed", - passed=10, - failed=0, - errors=0, - skipped=0, - duration=1.5, - output='{"summary": "all passed"}', - ) - - assert result_id is not None - assert isinstance(result_id, int) - - def test_get_test_results_by_task(self, temp_db_path): - """Test retrieving test results for a task.""" - db = Database(temp_db_path) - db.initialize() - - # Create project and task - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": "1.0", - "title": "Test Issue", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Test Task", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create test result - db.create_test_result( - task_id=task_id, - status="passed", - passed=10, - failed=0, - errors=0, - skipped=0, - duration=1.5, - output='{"summary": "all passed"}', - ) - - # Retrieve results - results = db.get_test_results_by_task(task_id) - - assert len(results) == 1 - assert results[0]["status"] == "passed" - assert results[0]["passed"] == 10 - assert results[0]["duration"] == 1.5 - - def test_test_results_foreign_key_to_task(self, temp_db_path): - """Test that test_results has foreign key to tasks table.""" - db = Database(temp_db_path) - db.initialize() - - # Verify schema includes task_id foreign key - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(test_results)") - columns = cursor.fetchall() - - column_names = [col[1] for col in columns] - assert "task_id" in column_names - - def test_multiple_test_runs_for_task(self, temp_db_path): - """Test storing multiple test runs for same task (retries).""" - db = Database(temp_db_path) - db.initialize() - - # Create task - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": "1.0", - "title": "Test Issue", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Test Task", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # First run: failed - db.create_test_result( - task_id=task_id, - status="failed", - passed=7, - failed=3, - errors=0, - skipped=0, - duration=2.0, - output='{"summary": "some failed"}', - ) - - # Second run: passed - db.create_test_result( - task_id=task_id, - status="passed", - passed=10, - failed=0, - errors=0, - skipped=0, - duration=1.8, - output='{"summary": "all passed"}', - ) - - # Get all results - results = db.get_test_results_by_task(task_id) - - assert len(results) == 2 - # Should be ordered by created_at (newest first or oldest first) - statuses = [r["status"] for r in results] - assert "failed" in statuses - assert "passed" in statuses - - -@pytest.mark.unit -class TestLintResults: - """Test lint_results table and operations (T091-T092).""" - - # T091: Test lint results database storage - def test_create_lint_result(self, temp_db_path): - """Test creating a lint result record in database.""" - db = Database(temp_db_path) - db.initialize() - - # Create project and task - project_id = db.create_project("test-project", "Test project for linting") - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": "1.0", - "title": "Test Issue", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Lint Task", - description="Test lint", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create lint result - result_id = db.create_lint_result( - task_id=task_id, - linter="ruff", - error_count=5, - warning_count=10, - files_linted=3, - output='{"findings": [{"code": "F401", "message": "unused import"}]}', - ) - - assert result_id is not None - assert isinstance(result_id, int) - - def test_get_lint_results_for_task(self, temp_db_path): - """Test retrieving lint results for a specific task.""" - db = Database(temp_db_path) - db.initialize() - - # Create project and task - project_id = db.create_project("test-project", "Test project for linting") - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": "1.0", - "title": "Test Issue", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Lint Task", - description="Test lint", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create multiple lint results for same task (ruff + eslint) - db.create_lint_result( - task_id=task_id, - linter="ruff", - error_count=5, - warning_count=10, - files_linted=3, - output='{"findings": []}', - ) - - db.create_lint_result( - task_id=task_id, - linter="eslint", - error_count=2, - warning_count=7, - files_linted=2, - output='{"findings": []}', - ) - - # Retrieve results - results = db.get_lint_results_for_task(task_id) - - assert len(results) == 2 - linters = {r["linter"] for r in results} - assert "ruff" in linters - assert "eslint" in linters - - # Verify data integrity - ruff_result = next(r for r in results if r["linter"] == "ruff") - assert ruff_result["error_count"] == 5 - assert ruff_result["warning_count"] == 10 - assert ruff_result["files_linted"] == 3 - - # T092: Test lint trend aggregation - def test_get_lint_trend(self, temp_db_path): - """Test aggregating lint results over time for trend analysis.""" - db = Database(temp_db_path) - db.initialize() - - # Create project - project_id = db.create_project("test-project", "Test project for trend analysis") - - # Create multiple tasks with lint results - for i in range(5): - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": f"{i+1}.0", - "title": f"Issue {i+1}", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number=f"{i+1}.0.1", - parent_issue_number=f"{i+1}.0", - title=f"Task {i+1}", - description="Test", - status=TaskStatus.COMPLETED, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Simulate improving quality over time (fewer errors each iteration) - db.create_lint_result( - task_id=task_id, - linter="ruff", - error_count=10 - i, # 10, 9, 8, 7, 6 - warning_count=20 - (i * 2), # 20, 18, 16, 14, 12 - files_linted=5, - output="{}", - ) - - # Get trend for last 7 days - trend = db.get_lint_trend(project_id, days=7) - - assert len(trend) > 0 - # Verify data structure (matches get_lint_trend implementation) - for entry in trend: - assert "date" in entry - assert "linter" in entry - assert "error_count" in entry - assert "warning_count" in entry - - def test_lint_results_foreign_key_to_task(self, temp_db_path): - """Test that lint_results has foreign key to tasks table.""" - db = Database(temp_db_path) - db.initialize() - - # Verify schema includes task_id foreign key - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(lint_results)") - columns = cursor.fetchall() - - column_names = [col[1] for col in columns] - assert "task_id" in column_names - - def test_multiple_linters_for_same_task(self, temp_db_path): - """Test storing results from multiple linters (ruff + eslint) for same task.""" - db = Database(temp_db_path) - db.initialize() - - # Create task - project_id = db.create_project("test-project", "Test project for multi-linter") - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": "1.0", - "title": "Test Issue", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Mixed Task", - description="Python + TypeScript", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Ruff result (Python) - db.create_lint_result( - task_id=task_id, - linter="ruff", - error_count=3, - warning_count=5, - files_linted=2, - output='{"findings": [{"code": "F401"}]}', - ) - - # ESLint result (TypeScript) - db.create_lint_result( - task_id=task_id, - linter="eslint", - error_count=1, - warning_count=2, - files_linted=1, - output='{"findings": [{"ruleId": "no-unused-vars"}]}', - ) - - # Get all results - results = db.get_lint_results_for_task(task_id) - - assert len(results) == 2 - # Verify both linters present - linters = {r["linter"] for r in results} - assert linters == {"ruff", "eslint"} - - def test_lint_output_json_storage(self, temp_db_path): - """Test that lint output JSON is stored and retrieved correctly.""" - db = Database(temp_db_path) - db.initialize() - - # Create task - project_id = db.create_project("test-project", "Test project for JSON storage") - issue_id = db.create_issue( - { - "project_id": project_id, - "issue_number": "1.0", - "title": "Test Issue", - "status": "pending", - "priority": 0, - "workflow_step": 1, - } - ) - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.0.1", - parent_issue_number="1.0", - title="Lint Task", - description="Test", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=1, - can_parallelize=False, - ) - - # Create lint result with complex JSON output - detailed_output = json.dumps( - { - "findings": [ - {"code": "F401", "message": "unused import 'os'", "line": 1}, - {"code": "E501", "message": "line too long", "line": 5}, - ], - "metadata": {"version": "0.1.0", "duration": "0.5s"}, - } - ) - - db.create_lint_result( - task_id=task_id, - linter="ruff", - error_count=2, - warning_count=0, - files_linted=1, - output=detailed_output, - ) - - # Retrieve and verify JSON - results = db.get_lint_results_for_task(task_id) - assert len(results) == 1 - - stored_output = results[0]["output"] - parsed_output = json.loads(stored_output) - - assert len(parsed_output["findings"]) == 2 - assert parsed_output["metadata"]["version"] == "0.1.0" diff --git a/tests/persistence/test_database_git_branches.py b/tests/persistence/test_database_git_branches.py deleted file mode 100644 index eb8f6a7d..00000000 --- a/tests/persistence/test_database_git_branches.py +++ /dev/null @@ -1,427 +0,0 @@ -"""Test suite for database git_branches table and methods. - -Following TDD methodology: RED → GREEN → REFACTOR -""" - -import pytest -import tempfile -from pathlib import Path -from datetime import datetime - -from codeframe.persistence.database import Database -from codeframe.core.models import Issue, TaskStatus - - -@pytest.fixture -def test_db(): - """Create a test database in memory.""" - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: - db_path = Path(tmp.name) - - db = Database(db_path) - db.initialize() - - yield db - - db.close() - db_path.unlink() - - -@pytest.fixture -def test_project(test_db): - """Create a test project.""" - - project_id = test_db.create_project("test_project", "Test Project project") - return project_id - - -@pytest.fixture -def test_issue(test_db, test_project): - """Create a test issue.""" - issue = Issue( - project_id=test_project, - issue_number="2.1", - title="Test Issue", - description="Test description", - status=TaskStatus.PENDING, - priority=0, - workflow_step=1, - ) - issue_id = test_db.create_issue(issue) - return issue_id - - -class TestGitBranchesSchema: - """Test git_branches table schema.""" - - def test_table_exists(self, test_db): - """Test that git_branches table exists.""" - cursor = test_db.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='git_branches'") - result = cursor.fetchone() - assert result is not None - - def test_table_columns(self, test_db): - """Test that git_branches table has correct columns.""" - cursor = test_db.conn.cursor() - cursor.execute("PRAGMA table_info(git_branches)") - columns = cursor.fetchall() - - column_names = [col[1] for col in columns] - - assert "id" in column_names - assert "issue_id" in column_names - assert "branch_name" in column_names - assert "created_at" in column_names - assert "merged_at" in column_names - assert "merge_commit" in column_names - assert "status" in column_names - - def test_status_constraint(self, test_db, test_issue): - """Test that status column has CHECK constraint.""" - # Valid statuses should work - test_db.create_git_branch(test_issue, "test-branch") - - # Invalid status should fail - cursor = test_db.conn.cursor() - with pytest.raises(Exception): # sqlite3.IntegrityError - cursor.execute( - "INSERT INTO git_branches (issue_id, branch_name, status) VALUES (?, ?, ?)", - (test_issue, "invalid-branch", "invalid_status"), - ) - - -class TestCreateGitBranch: - """Test create_git_branch method.""" - - def test_create_git_branch_basic(self, test_db, test_issue): - """Test creating a git branch record.""" - branch_id = test_db.create_git_branch(test_issue, "issue-2.1-test-feature") - - assert branch_id is not None - assert isinstance(branch_id, int) - - # Verify in database - cursor = test_db.conn.cursor() - cursor.execute("SELECT * FROM git_branches WHERE id = ?", (branch_id,)) - row = cursor.fetchone() - - assert row is not None - assert row["issue_id"] == test_issue - assert row["branch_name"] == "issue-2.1-test-feature" - assert row["status"] == "active" - assert row["merged_at"] is None - assert row["merge_commit"] is None - - def test_create_git_branch_with_timestamp(self, test_db, test_issue): - """Test that created_at timestamp is set.""" - branch_id = test_db.create_git_branch(test_issue, "test-branch") - - cursor = test_db.conn.cursor() - cursor.execute("SELECT created_at FROM git_branches WHERE id = ?", (branch_id,)) - row = cursor.fetchone() - - assert row["created_at"] is not None - # Verify it's a valid timestamp format - created_at = row["created_at"] - assert isinstance(created_at, str) - # Should be in SQLite timestamp format (YYYY-MM-DD HH:MM:SS) - datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S") - - def test_create_git_branch_duplicate_issue(self, test_db, test_issue): - """Test creating multiple branches for same issue (should be allowed).""" - # Create first branch - branch_id1 = test_db.create_git_branch(test_issue, "branch-1") - - # Create second branch for same issue - branch_id2 = test_db.create_git_branch(test_issue, "branch-2") - - assert branch_id1 != branch_id2 - - # Both should exist - cursor = test_db.conn.cursor() - cursor.execute("SELECT COUNT(*) FROM git_branches WHERE issue_id = ?", (test_issue,)) - count = cursor.fetchone()[0] - assert count == 2 - - def test_create_git_branch_nonexistent_issue(self, test_db): - """Test creating branch for nonexistent issue.""" - # Should fail due to foreign key constraint - with pytest.raises(Exception): # sqlite3.IntegrityError - test_db.create_git_branch(99999, "test-branch") - - -class TestGetBranchForIssue: - """Test get_branch_for_issue method.""" - - def test_get_branch_for_issue_exists(self, test_db, test_issue): - """Test getting branch for issue that has one.""" - branch_id = test_db.create_git_branch(test_issue, "test-branch") - - result = test_db.get_branch_for_issue(test_issue) - - assert result is not None - assert result["id"] == branch_id - assert result["issue_id"] == test_issue - assert result["branch_name"] == "test-branch" - assert result["status"] == "active" - - def test_get_branch_for_issue_not_found(self, test_db, test_issue): - """Test getting branch for issue with no branches.""" - result = test_db.get_branch_for_issue(test_issue) - - assert result is None - - def test_get_branch_for_issue_multiple_branches(self, test_db, test_issue): - """Test getting branch when issue has multiple branches (returns most recent).""" - # Create multiple branches - test_db.create_git_branch(test_issue, "branch-1") - test_db.create_git_branch(test_issue, "branch-2") - branch_id3 = test_db.create_git_branch(test_issue, "branch-3") - - result = test_db.get_branch_for_issue(test_issue) - - # Should return most recent (highest id) - assert result is not None - assert result["id"] == branch_id3 - assert result["branch_name"] == "branch-3" - - def test_get_branch_for_issue_only_active(self, test_db, test_issue): - """Test getting only active branches (not merged).""" - # Create active branch - active_id = test_db.create_git_branch(test_issue, "active-branch") - - # Create and merge another branch - merged_id = test_db.create_git_branch(test_issue, "merged-branch") - test_db.mark_branch_merged(merged_id, "abc123") - - result = test_db.get_branch_for_issue(test_issue) - - # Should return active branch, not merged one - assert result is not None - assert result["id"] == active_id - assert result["status"] == "active" - - -class TestMarkBranchMerged: - """Test mark_branch_merged method.""" - - def test_mark_branch_merged_success(self, test_db, test_issue): - """Test marking a branch as merged.""" - branch_id = test_db.create_git_branch(test_issue, "test-branch") - - # Mark as merged - result = test_db.mark_branch_merged(branch_id, "abc123def456") - - assert result == 1 # One row updated - - # Verify in database - cursor = test_db.conn.cursor() - cursor.execute("SELECT * FROM git_branches WHERE id = ?", (branch_id,)) - row = cursor.fetchone() - - assert row["status"] == "merged" - assert row["merge_commit"] == "abc123def456" - assert row["merged_at"] is not None - - # Verify merged_at is valid timestamp - merged_at = row["merged_at"] - datetime.strptime(merged_at, "%Y-%m-%d %H:%M:%S") - - def test_mark_branch_merged_nonexistent(self, test_db): - """Test marking nonexistent branch as merged.""" - result = test_db.mark_branch_merged(99999, "abc123") - - assert result == 0 # No rows updated - - def test_mark_branch_merged_already_merged(self, test_db, test_issue): - """Test marking already-merged branch (should update).""" - branch_id = test_db.create_git_branch(test_issue, "test-branch") - - # Mark as merged first time - test_db.mark_branch_merged(branch_id, "commit1") - - # Mark as merged again with different commit - result = test_db.mark_branch_merged(branch_id, "commit2") - - assert result == 1 - - # Should have updated commit hash - cursor = test_db.conn.cursor() - cursor.execute("SELECT merge_commit FROM git_branches WHERE id = ?", (branch_id,)) - row = cursor.fetchone() - assert row["merge_commit"] == "commit2" - - -class TestGetBranchesByStatus: - """Test querying branches by status.""" - - def test_get_active_branches(self, test_db, test_issue): - """Test getting all active branches.""" - # Create mix of branches - active_id1 = test_db.create_git_branch(test_issue, "active-1") - active_id2 = test_db.create_git_branch(test_issue, "active-2") - - merged_id = test_db.create_git_branch(test_issue, "merged-1") - test_db.mark_branch_merged(merged_id, "abc123") - - # Get active branches - branches = test_db.get_branches_by_status("active") - - assert len(branches) == 2 - branch_ids = [b["id"] for b in branches] - assert active_id1 in branch_ids - assert active_id2 in branch_ids - assert merged_id not in branch_ids - - def test_get_merged_branches(self, test_db, test_issue): - """Test getting all merged branches.""" - # Create branches - active_id = test_db.create_git_branch(test_issue, "active-1") - - merged_id1 = test_db.create_git_branch(test_issue, "merged-1") - test_db.mark_branch_merged(merged_id1, "abc123") - - merged_id2 = test_db.create_git_branch(test_issue, "merged-2") - test_db.mark_branch_merged(merged_id2, "def456") - - # Get merged branches - branches = test_db.get_branches_by_status("merged") - - assert len(branches) == 2 - branch_ids = [b["id"] for b in branches] - assert merged_id1 in branch_ids - assert merged_id2 in branch_ids - assert active_id not in branch_ids - - def test_get_branches_by_status_empty(self, test_db): - """Test getting branches when none exist.""" - branches = test_db.get_branches_by_status("active") - - assert len(branches) == 0 - assert branches == [] - - -class TestGetAllBranchesForIssue: - """Test getting all branches for an issue.""" - - def test_get_all_branches_for_issue(self, test_db, test_issue): - """Test getting all branches (active and merged) for an issue.""" - # Create multiple branches - id1 = test_db.create_git_branch(test_issue, "branch-1") - test_db.create_git_branch(test_issue, "branch-2") - - # Merge one - test_db.mark_branch_merged(id1, "abc123") - - # Get all branches - branches = test_db.get_all_branches_for_issue(test_issue) - - assert len(branches) == 2 - - # Check both are present - branch_names = [b["branch_name"] for b in branches] - assert "branch-1" in branch_names - assert "branch-2" in branch_names - - def test_get_all_branches_for_issue_none(self, test_db, test_issue): - """Test getting branches for issue with none.""" - branches = test_db.get_all_branches_for_issue(test_issue) - - assert len(branches) == 0 - - -class TestBranchCleanup: - """Test branch cleanup and status transitions.""" - - def test_mark_branch_abandoned(self, test_db, test_issue): - """Test marking a branch as abandoned.""" - branch_id = test_db.create_git_branch(test_issue, "abandoned-branch") - - # Mark as abandoned - result = test_db.mark_branch_abandoned(branch_id) - - assert result == 1 - - # Verify status - cursor = test_db.conn.cursor() - cursor.execute("SELECT status FROM git_branches WHERE id = ?", (branch_id,)) - row = cursor.fetchone() - assert row["status"] == "abandoned" - - def test_delete_branch_record(self, test_db, test_issue): - """Test deleting a branch record.""" - branch_id = test_db.create_git_branch(test_issue, "temp-branch") - - # Delete record - result = test_db.delete_git_branch(branch_id) - - assert result == 1 - - # Verify deleted - cursor = test_db.conn.cursor() - cursor.execute("SELECT * FROM git_branches WHERE id = ?", (branch_id,)) - row = cursor.fetchone() - assert row is None - - -class TestBranchStatistics: - """Test branch statistics queries.""" - - def test_count_branches_by_issue(self, test_db, test_project): - """Test counting branches per issue.""" - # Create multiple issues - issue1_id = test_db.create_issue( - Issue( - project_id=test_project, - issue_number="1.1", - title="Issue 1", - status=TaskStatus.PENDING, - priority=0, - workflow_step=1, - ) - ) - - issue2_id = test_db.create_issue( - Issue( - project_id=test_project, - issue_number="1.2", - title="Issue 2", - status=TaskStatus.PENDING, - priority=0, - workflow_step=1, - ) - ) - - # Create branches - test_db.create_git_branch(issue1_id, "branch-1-1") - test_db.create_git_branch(issue1_id, "branch-1-2") - test_db.create_git_branch(issue2_id, "branch-2-1") - - # Count for issue 1 - count = test_db.count_branches_for_issue(issue1_id) - assert count == 2 - - # Count for issue 2 - count = test_db.count_branches_for_issue(issue2_id) - assert count == 1 - - def test_get_branch_statistics(self, test_db, test_issue): - """Test getting overall branch statistics.""" - # Create branches in different states - test_db.create_git_branch(test_issue, "active-1") - test_db.create_git_branch(test_issue, "active-2") - - merged_id = test_db.create_git_branch(test_issue, "merged-1") - test_db.mark_branch_merged(merged_id, "abc123") - - abandoned_id = test_db.create_git_branch(test_issue, "abandoned-1") - test_db.mark_branch_abandoned(abandoned_id) - - # Get statistics - stats = test_db.get_branch_statistics() - - assert stats["total"] == 4 - assert stats["active"] == 2 - assert stats["merged"] == 1 - assert stats["abandoned"] == 1 diff --git a/tests/persistence/test_database_issues.py b/tests/persistence/test_database_issues.py deleted file mode 100644 index 74ced71f..00000000 --- a/tests/persistence/test_database_issues.py +++ /dev/null @@ -1,1026 +0,0 @@ -"""Tests for database Issues/Tasks hierarchical model (cf-16.2). - -Following TDD: These tests are written FIRST, before implementation. -Target: >90% coverage for Issues table and related operations. - -Requirements from CONCEPTS_RESOLVED.md: -- Issues table with hierarchical numbering (e.g., "1.5") -- Tasks table enhanced with issue_id, task_number (e.g., "1.5.3") -- CRUD operations for Issues -- Query operations for Issue-Task relationships -- Unique constraints and foreign key relationships -""" - -import pytest -from datetime import datetime -from codeframe.persistence.database import Database -from codeframe.core.models import TaskStatus, Issue - - -@pytest.fixture -def db(temp_db_path): - """Create and initialize database with proper async cleanup. - - This fixture replaces the inline Database creation pattern - to ensure async connections are properly closed and prevent - pytest from hanging during teardown. - """ - database = Database(temp_db_path) - database.initialize() - - yield database - - # Close async connection if it was opened (prevents hanging) - if database._async_conn: - import asyncio - - try: - asyncio.get_event_loop().run_until_complete(database.close_async()) - except RuntimeError: - asyncio.run(database.close_async()) - database.close() - - -@pytest.mark.unit -class TestIssuesTableCreation: - """Test Issues table schema creation and migration.""" - - def test_issues_table_created(self, db): - """Test that Issues table is created with correct schema.""" - - # Verify issues table exists - cursor = db.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='issues'") - result = cursor.fetchone() - assert result is not None, "Issues table was not created" - - def test_issues_table_columns(self, db): - """Test that Issues table has all required columns.""" - - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(issues)") - columns = {row[1]: row[2] for row in cursor.fetchall()} # column_name: type - - # Verify all required columns exist - assert "id" in columns - assert "project_id" in columns - assert "issue_number" in columns - assert "title" in columns - assert "description" in columns - assert "status" in columns - assert "priority" in columns - assert "workflow_step" in columns - assert "created_at" in columns - assert "completed_at" in columns - - def test_tasks_table_enhanced_columns(self, db): - """Test that Tasks table has new columns for Issue relationship.""" - - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(tasks)") - columns = {row[1]: row[2] for row in cursor.fetchall()} - - # Verify new columns exist - assert "issue_id" in columns - assert "task_number" in columns - assert "parent_issue_number" in columns - assert "can_parallelize" in columns - - def test_issues_indexes_created(self, db): - """Test that proper indexes are created for Issues.""" - - cursor = db.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='index'") - indexes = [row[0] for row in cursor.fetchall()] - - # Verify indexes exist - assert any( - "idx_issues_number" in idx for idx in indexes - ), "Index on issues(project_id, issue_number) not found" - assert any( - "idx_tasks_issue_number" in idx for idx in indexes - ), "Index on tasks(parent_issue_number) not found" - - -@pytest.mark.unit -class TestIssueCRUD: - """Test Issue CRUD operations.""" - - def test_create_issue_minimal(self, db): - """Test creating an issue with minimal required fields.""" - - project_id = db.create_project("test-project", "Test Project project") - - issue = Issue( - project_id=project_id, - issue_number="1.5", - title="Implement database migration", - description="Add Issues table with hierarchical model", - status=TaskStatus.PENDING, - priority=1, - ) - issue_id = db.create_issue(issue) - - assert issue_id is not None - assert isinstance(issue_id, int) - assert issue_id > 0 - - def test_create_issue_full(self, db): - """Test creating an issue with all fields.""" - - project_id = db.create_project("test-project", "Test Project project") - - issue = Issue( - project_id=project_id, - issue_number="2.3", - title="API endpoint for issues", - description="Create REST API for issue management", - status=TaskStatus.IN_PROGRESS, - priority=0, - workflow_step=5, - ) - issue_id = db.create_issue(issue) - - assert issue_id is not None - - # Verify it was created correctly - saved_issue = db.get_issue(issue_id) - assert saved_issue.issue_number == "2.3" - assert saved_issue.title == "API endpoint for issues" - assert saved_issue.status == TaskStatus.IN_PROGRESS - assert saved_issue.priority == 0 - assert saved_issue.workflow_step == 5 - - def test_get_issue_by_id(self, db): - """Test retrieving an issue by ID.""" - - project_id = db.create_project("test-project", "Test Project project") - issue = Issue( - project_id=project_id, - issue_number="1.1", - title="Test Issue", - description="This is a test", - status=TaskStatus.PENDING, - priority=2, - ) - issue_id = db.create_issue(issue) - - # Retrieve issue - issue = db.get_issue(issue_id) - - assert issue is not None - assert issue.id == issue_id - assert issue.project_id == project_id - assert issue.issue_number == "1.1" - assert issue.title == "Test Issue" - assert issue.description == "This is a test" - assert issue.status == TaskStatus.PENDING - assert issue.priority == 2 - assert issue.created_at is not None - - def test_get_nonexistent_issue_returns_none(self, db): - """Test that getting non-existent issue returns None.""" - - issue = db.get_issue(99999) - assert issue is None - - def test_list_issues_by_project(self, db): - """Test listing all issues for a project.""" - - project_id = db.create_project("test-project", "Test Project project") - - # Create multiple issues - db.create_issue( - Issue( - project_id=project_id, - issue_number="1.1", - title="Issue 1", - description="Description 1", - status=TaskStatus.PENDING, - priority=1, - ) - ) - db.create_issue( - Issue( - project_id=project_id, - issue_number="1.2", - title="Issue 2", - description="Description 2", - status=TaskStatus.IN_PROGRESS, - priority=2, - ) - ) - db.create_issue( - Issue( - project_id=project_id, - issue_number="2.1", - title="Issue 3", - description="Description 3", - status=TaskStatus.COMPLETED, - priority=3, - ) - ) - - # List all issues for project - issues = db.list_issues(project_id) - - assert len(issues) == 3 - issue_numbers = [i.issue_number for i in issues] - assert "1.1" in issue_numbers - assert "1.2" in issue_numbers - assert "2.1" in issue_numbers - - def test_list_issues_empty_project(self, db): - """Test listing issues for project with no issues.""" - - project_id = db.create_project("test-project", "Test Project project") - issues = db.list_issues(project_id) - - assert issues == [] - - def test_list_issues_filters_by_project(self, db): - """Test that list_issues only returns issues for specified project.""" - - project1_id = db.create_project("project1", "Project1 project") - project2_id = db.create_project("project2", "Project2 project") - - # Create issues in different projects - db.create_issue( - Issue( - project_id=project1_id, - issue_number="1.1", - title="Project 1 Issue", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - db.create_issue( - Issue( - project_id=project2_id, - issue_number="1.1", - title="Project 2 Issue", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # List issues for project1 - issues = db.list_issues(project1_id) - - assert len(issues) == 1 - assert issues[0].title == "Project 1 Issue" - - def test_update_issue_status(self, db): - """Test updating issue status.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.1", - title="Test", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # Update status - db.update_issue(issue_id, {"status": "in_progress"}) - - # Verify update - issue = db.get_issue(issue_id) - assert issue.status == TaskStatus.IN_PROGRESS - - def test_update_issue_multiple_fields(self, db): - """Test updating multiple issue fields at once.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.1", - title="Original Title", - description="Original Desc", - status=TaskStatus.PENDING, - priority=2, - ) - ) - - # Update multiple fields - db.update_issue( - issue_id, - { - "title": "Updated Title", - "description": "Updated Description", - "status": "completed", - "priority": 0, - "workflow_step": 10, - }, - ) - - # Verify updates - issue = db.get_issue(issue_id) - assert issue.title == "Updated Title" - assert issue.description == "Updated Description" - assert issue.status == TaskStatus.COMPLETED - assert issue.priority == 0 - assert issue.workflow_step == 10 - - def test_update_issue_with_completed_timestamp(self, db): - """Test that completing an issue sets completed_at timestamp.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.1", - title="Test", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Complete the issue - db.update_issue( - issue_id, {"status": "completed", "completed_at": datetime.now().isoformat()} - ) - - # Verify completed_at is set - issue = db.get_issue(issue_id) - assert issue.completed_at is not None - - def test_update_nonexistent_issue(self, db): - """Test that updating non-existent issue returns 0.""" - - result = db.update_issue(99999, {"status": "completed"}) - assert result == 0 # 0 rows affected - - -@pytest.mark.unit -class TestTaskIssueRelationship: - """Test Task-Issue relationship and enhanced task operations.""" - - def test_create_task_with_issue_id(self, db): - """Test creating a task linked to an issue.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Parent Issue", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Create task with issue relationship - task_id = db.create_task_with_issue( - project_id=project_id, - issue_id=issue_id, - task_number="1.5.1", - parent_issue_number="1.5", - title="Implement schema", - description="Create database schema", - status=TaskStatus.PENDING, - priority=1, - workflow_step=3, - can_parallelize=False, - ) - - assert task_id is not None - assert task_id > 0 - - @pytest.mark.asyncio - async def test_get_tasks_by_issue(self, db): - """Test retrieving all tasks for an issue.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Parent Issue", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Create multiple tasks for the issue - db.create_task_with_issue( - project_id, - issue_id, - "1.5.1", - "1.5", - "Task 1", - "Desc 1", - TaskStatus.PENDING, - 1, - 1, - False, - ) - db.create_task_with_issue( - project_id, - issue_id, - "1.5.2", - "1.5", - "Task 2", - "Desc 2", - TaskStatus.IN_PROGRESS, - 1, - 2, - True, - ) - db.create_task_with_issue( - project_id, - issue_id, - "1.5.3", - "1.5", - "Task 3", - "Desc 3", - TaskStatus.COMPLETED, - 1, - 3, - False, - ) - - # Get tasks by issue (async) - tasks = await db.get_tasks_by_issue(issue_id) - - assert len(tasks) == 3 - task_numbers = [t.task_number for t in tasks] - assert "1.5.1" in task_numbers - assert "1.5.2" in task_numbers - assert "1.5.3" in task_numbers - - @pytest.mark.asyncio - async def test_get_tasks_by_issue_empty(self, db): - """Test getting tasks for issue with no tasks.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Issue", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - tasks = await db.get_tasks_by_issue(issue_id) - assert tasks == [] - - def test_task_can_parallelize_flag(self, db): - """Test that can_parallelize flag is stored correctly.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Issue", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Create parallelizable task - task_id = db.create_task_with_issue( - project_id, - issue_id, - "1.5.1", - "1.5", - "Parallel Task", - "Can run in parallel", - TaskStatus.PENDING, - 1, - 1, - can_parallelize=True, - ) - - # Verify flag - cursor = db.conn.cursor() - cursor.execute("SELECT can_parallelize FROM tasks WHERE id = ?", (task_id,)) - result = cursor.fetchone() - assert result[0] == 1 # SQLite stores boolean as 1/0 - - def test_get_tasks_by_parent_issue_number(self, db): - """Test querying tasks by parent_issue_number.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="2.3", - title="Issue", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Create tasks - db.create_task_with_issue( - project_id, issue_id, "2.3.1", "2.3", "Task 1", "Desc", TaskStatus.PENDING, 1, 1, False - ) - db.create_task_with_issue( - project_id, issue_id, "2.3.2", "2.3", "Task 2", "Desc", TaskStatus.PENDING, 1, 1, False - ) - - # Query by parent issue number - tasks = db.get_tasks_by_parent_issue_number("2.3") - - assert len(tasks) == 2 - for task in tasks: - assert task.parent_issue_number == "2.3" - - -@pytest.mark.unit -class TestIssueConstraints: - """Test data integrity constraints for Issues.""" - - def test_unique_issue_number_per_project(self, db): - """Test that issue_number must be unique within a project.""" - - project_id = db.create_project("test-project", "Test Project project") - - # Create first issue - db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Issue 1", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # Try to create duplicate issue number - with pytest.raises(Exception): # sqlite3.IntegrityError - db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Issue 2", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - def test_same_issue_number_different_projects_allowed(self, db): - """Test that same issue_number is allowed in different projects.""" - - project1_id = db.create_project("project1", "Project1 project") - project2_id = db.create_project("project2", "Project2 project") - - # Create issues with same number in different projects - should succeed - issue1_id = db.create_issue( - Issue( - project_id=project1_id, - issue_number="1.1", - title="Issue 1", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - issue2_id = db.create_issue( - Issue( - project_id=project2_id, - issue_number="1.1", - title="Issue 2", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - assert issue1_id != issue2_id - - def test_issue_status_constraint(self, db): - """Test that issue status must be valid.""" - - project_id = db.create_project("test-project", "Test Project project") - - # Valid statuses: pending, in_progress, completed, failed - cursor = db.conn.cursor() - - # Try invalid status - with pytest.raises(Exception): # sqlite3.IntegrityError - cursor.execute( - "INSERT INTO issues (project_id, issue_number, title, status, priority) VALUES (?, ?, ?, ?, ?)", - (project_id, "1.1", "Test", "INVALID_STATUS", 1), - ) - - def test_issue_priority_constraint(self, db): - """Test that issue priority must be between 0 and 4.""" - - project_id = db.create_project("test-project", "Test Project project") - cursor = db.conn.cursor() - - # Try priority out of range - with pytest.raises(Exception): # sqlite3.IntegrityError - cursor.execute( - "INSERT INTO issues (project_id, issue_number, title, status, priority) VALUES (?, ?, ?, ?, ?)", - (project_id, "1.1", "Test", "pending", 10), # Invalid priority - ) - - def test_issue_foreign_key_to_project(self, db): - """Test foreign key relationship from issue to project.""" - - # Enable foreign keys - db.conn.execute("PRAGMA foreign_keys = ON") - - cursor = db.conn.cursor() - - # Try to create issue with non-existent project_id - try: - cursor.execute( - "INSERT INTO issues (project_id, issue_number, title, status, priority) VALUES (?, ?, ?, ?, ?)", - (99999, "1.1", "Test", "pending", 1), - ) - db.conn.commit() - # If we get here, foreign keys aren't enforced - except Exception: - # Foreign keys enforced - good! - pass - - def test_task_foreign_key_to_issue(self, db): - """Test foreign key relationship from task to issue.""" - - # Enable foreign keys - db.conn.execute("PRAGMA foreign_keys = ON") - - project_id = db.create_project("test-project", "Test Project project") - cursor = db.conn.cursor() - - # Try to create task with non-existent issue_id - try: - cursor.execute( - """INSERT INTO tasks - (project_id, issue_id, task_number, title, status, priority) - VALUES (?, ?, ?, ?, ?, ?)""", - (project_id, 99999, "1.1.1", "Test", "pending", 1), - ) - db.conn.commit() - except Exception: - # Foreign key enforced - good! - pass - - -@pytest.mark.unit -class TestIssueTaskQueries: - """Test complex queries involving Issues and Tasks.""" - - def test_get_issue_with_task_counts(self, db): - """Test getting issue with count of associated tasks.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Issue", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Create tasks - for i in range(3): - db.create_task_with_issue( - project_id, - issue_id, - f"1.5.{i+1}", - "1.5", - f"Task {i+1}", - "Desc", - TaskStatus.PENDING, - 1, - 1, - False, - ) - - # Get issue with task count - issue_with_counts = db.get_issue_with_task_counts(issue_id) - - assert issue_with_counts.id == issue_id - assert issue_with_counts.task_count == 3 - - def test_get_issue_completion_status(self, db): - """Test calculating issue completion based on task statuses.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Issue", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Create tasks with different statuses - db.create_task_with_issue( - project_id, - issue_id, - "1.5.1", - "1.5", - "Task 1", - "Desc", - TaskStatus.COMPLETED, - 1, - 1, - False, - ) - db.create_task_with_issue( - project_id, - issue_id, - "1.5.2", - "1.5", - "Task 2", - "Desc", - TaskStatus.COMPLETED, - 1, - 1, - False, - ) - db.create_task_with_issue( - project_id, issue_id, "1.5.3", "1.5", "Task 3", "Desc", TaskStatus.PENDING, 1, 1, False - ) - - # Calculate completion - completion = db.get_issue_completion_status(issue_id) - - assert completion["total_tasks"] == 3 - assert completion["completed_tasks"] == 2 - assert completion["completion_percentage"] == pytest.approx(66.67, rel=0.1) - - def test_list_issues_with_progress(self, db): - """Test listing issues with their progress metrics.""" - - project_id = db.create_project("test-project", "Test Project project") - - # Create issue 1 with tasks - issue1_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.1", - title="Issue 1", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - db.create_task_with_issue( - project_id, issue1_id, "1.1.1", "1.1", "Task", "Desc", TaskStatus.COMPLETED, 1, 1, False - ) - - # Create issue 2 with tasks - issue2_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.2", - title="Issue 2", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - db.create_task_with_issue( - project_id, issue2_id, "1.2.1", "1.2", "Task", "Desc", TaskStatus.PENDING, 1, 1, False - ) - - # List with progress - issues = db.list_issues_with_progress(project_id) - - assert len(issues) == 2 - # Each issue should have task counts - for issue in issues: - assert "task_count" in issue - - -@pytest.mark.integration -class TestIssueTaskIntegration: - """Integration tests for Issue-Task workflow.""" - - @pytest.mark.asyncio - async def test_complete_issue_workflow(self, db): - """Test complete workflow from issue creation to completion.""" - - # 1. Create project - project_id = db.create_project("my-app", "My App project") - - # 2. Create issue - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.5", - title="Database Migration", - description="Implement hierarchical Issue/Task model", - status=TaskStatus.PENDING, - priority=0, - ) - ) - - # 3. Update issue to in_progress - db.update_issue(issue_id, {"status": "in_progress"}) - - # 4. Create subtasks - task1_id = db.create_task_with_issue( - project_id, - issue_id, - "1.5.1", - "1.5", - "Create Issues table", - "Schema definition", - TaskStatus.PENDING, - 0, - 1, - False, - ) - - task2_id = db.create_task_with_issue( - project_id, - issue_id, - "1.5.2", - "1.5", - "Write tests", - "TDD approach", - TaskStatus.PENDING, - 0, - 2, - False, - ) - - # 5. Complete tasks - cursor = db.conn.cursor() - cursor.execute( - "UPDATE tasks SET status = ? WHERE id = ?", (TaskStatus.COMPLETED.value, task1_id) - ) - cursor.execute( - "UPDATE tasks SET status = ? WHERE id = ?", (TaskStatus.COMPLETED.value, task2_id) - ) - db.conn.commit() - - # 6. Complete issue - db.update_issue( - issue_id, {"status": "completed", "completed_at": datetime.now().isoformat()} - ) - - # 7. Verify final state - issue = db.get_issue(issue_id) - assert issue.status == TaskStatus.COMPLETED - assert issue.completed_at is not None - - tasks = await db.get_tasks_by_issue(issue_id) - assert all(t.status == TaskStatus.COMPLETED for t in tasks) - - def test_parallel_task_execution(self, db): - """Test workflow with parallelizable tasks.""" - - project_id = db.create_project("test-project", "Test Project project") - issue_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="2.1", - title="Feature X", - description="Desc", - status=TaskStatus.IN_PROGRESS, - priority=1, - ) - ) - - # Create tasks, some parallelizable - db.create_task_with_issue( - project_id, - issue_id, - "2.1.1", - "2.1", - "Backend API", - "Desc", - TaskStatus.IN_PROGRESS, - 1, - 1, - True, - ) - db.create_task_with_issue( - project_id, - issue_id, - "2.1.2", - "2.1", - "Frontend UI", - "Desc", - TaskStatus.IN_PROGRESS, - 1, - 1, - True, - ) - db.create_task_with_issue( - project_id, - issue_id, - "2.1.3", - "2.1", - "Integration test", - "Desc", - TaskStatus.PENDING, - 1, - 2, - False, - ) - - # Get parallelizable tasks - cursor = db.conn.cursor() - cursor.execute( - """SELECT * FROM tasks - WHERE issue_id = ? AND can_parallelize = 1""", - (issue_id,), - ) - parallel_tasks = cursor.fetchall() - - assert len(parallel_tasks) == 2 - - @pytest.mark.asyncio - async def test_hierarchical_numbering_consistency(self, db): - """Test that hierarchical numbering is consistent.""" - - project_id = db.create_project("test-project", "Test Project project") - - # Create issues with hierarchical numbers - db.create_issue( - Issue( - project_id=project_id, - issue_number="1", - title="Epic 1", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - issue2_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1.1", - title="Story 1.1", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - db.create_issue( - Issue( - project_id=project_id, - issue_number="1.2", - title="Story 1.2", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - db.create_issue( - Issue( - project_id=project_id, - issue_number="2", - title="Epic 2", - description="Desc", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # Create tasks for story 1.1 - db.create_task_with_issue( - project_id, issue2_id, "1.1.1", "1.1", "Task 1", "Desc", TaskStatus.PENDING, 1, 1, False - ) - db.create_task_with_issue( - project_id, issue2_id, "1.1.2", "1.1", "Task 2", "Desc", TaskStatus.PENDING, 1, 1, False - ) - - # Verify hierarchy - issues = db.list_issues(project_id) - issue_numbers = [i.issue_number for i in issues] - - assert "1" in issue_numbers - assert "1.1" in issue_numbers - assert "1.2" in issue_numbers - assert "2" in issue_numbers - - tasks = await db.get_tasks_by_issue(issue2_id) - task_numbers = [t.task_number for t in tasks] - - assert "1.1.1" in task_numbers - assert "1.1.2" in task_numbers diff --git a/tests/persistence/test_database_schema.py b/tests/persistence/test_database_schema.py deleted file mode 100644 index ecccf4b8..00000000 --- a/tests/persistence/test_database_schema.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for database schema changes (project refactoring).""" - -import pytest -from codeframe.persistence.database import Database - - -def test_projects_table_has_new_columns(): - """Verify projects table has new schema columns.""" - db_path = ":memory:" - db = Database(db_path) - db.initialize() - - # Get table schema - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(projects)") - columns = {row[1]: row[2] for row in cursor.fetchall()} - - # Verify new columns exist - assert "description" in columns, "Missing description column" - assert "source_type" in columns, "Missing source_type column" - assert "source_location" in columns, "Missing source_location column" - assert "source_branch" in columns, "Missing source_branch column" - assert "workspace_path" in columns, "Missing workspace_path column" - assert "git_initialized" in columns, "Missing git_initialized column" - assert "current_commit" in columns, "Missing current_commit column" - - # Verify old columns removed - assert "root_path" not in columns, "root_path should be removed" - - -def test_source_type_check_constraint(): - """Verify source_type has CHECK constraint.""" - db_path = ":memory:" - db = Database(db_path) - db.initialize() - - # Try to insert invalid source_type - cursor = db.conn.cursor() - with pytest.raises(Exception) as exc_info: - cursor.execute( - """ - INSERT INTO projects (name, description, source_type, workspace_path) - VALUES ('test', 'desc', 'invalid_type', '/tmp/test') - """ - ) - db.conn.commit() - - assert "CHECK constraint failed" in str(exc_info.value) - - -def test_description_not_null(): - """Verify description is required (NOT NULL).""" - db_path = ":memory:" - db = Database(db_path) - db.initialize() - - cursor = db.conn.cursor() - with pytest.raises(Exception) as exc_info: - cursor.execute( - """ - INSERT INTO projects (name, workspace_path) - VALUES ('test', '/tmp/test') - """ - ) - db.conn.commit() - - assert "NOT NULL constraint failed" in str(exc_info.value) diff --git a/tests/persistence/test_database_todos.py b/tests/persistence/test_database_todos.py deleted file mode 100644 index 578b1ac4..00000000 --- a/tests/persistence/test_database_todos.py +++ /dev/null @@ -1,439 +0,0 @@ -"""Tests for database TODOs: issue dependencies and audit logging (cf-207). - -Following TDD: These tests are written FIRST, before implementation. -Tests the P0 items from the database TODOs implementation plan. - -Tests cover: -- Issue depends_on field parsing and storage -- Audit logging for PROJECT_UPDATED event -- Audit logging for PROJECT_DELETED event -""" - -import json -import pytest -from codeframe.persistence.database import Database -from codeframe.core.models import TaskStatus, Issue - - -@pytest.fixture -def db(temp_db_path): - """Create and initialize database with proper async cleanup.""" - database = Database(temp_db_path) - database.initialize() - - yield database - - # Close async connection if it was opened (prevents hanging) - if database._async_conn: - import asyncio - - try: - asyncio.get_event_loop().run_until_complete(database.close_async()) - except RuntimeError: - asyncio.run(database.close_async()) - database.close() - - -@pytest.mark.unit -class TestIssueDependsOnColumn: - """Test that issues table has depends_on column for dependency tracking.""" - - def test_issues_table_has_depends_on_column(self, db): - """Test that issues table has depends_on column after migration.""" - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(issues)") - columns = {row[1]: row[2] for row in cursor.fetchall()} - - assert "depends_on" in columns, "issues table should have depends_on column" - assert columns["depends_on"] == "TEXT", "depends_on column should be TEXT type" - - -@pytest.mark.unit -class TestIssueDependencyCRUD: - """Test Issue dependency CRUD operations.""" - - def test_create_issue_with_depends_on(self, db): - """Test creating an issue with dependencies.""" - project_id = db.create_project("test-project", "Test Project") - - # Create first issue (no dependencies) - issue1_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1", - title="Issue 1", - description="First issue", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # Create second issue that depends on first - issue2 = Issue( - project_id=project_id, - issue_number="2", - title="Issue 2", - description="Depends on Issue 1", - status=TaskStatus.PENDING, - priority=1, - ) - # Pass depends_on as dict field - issue2_dict = { - "project_id": project_id, - "issue_number": "2", - "title": "Issue 2", - "description": "Depends on Issue 1", - "status": "pending", - "priority": 1, - "depends_on": json.dumps([str(issue1_id)]), # JSON string format - } - issue2_id = db.create_issue(issue2_dict) - - assert issue2_id is not None - assert issue2_id > 0 - - def test_get_issues_with_tasks_parses_depends_on(self, db): - """Test that get_issues_with_tasks correctly parses depends_on field.""" - project_id = db.create_project("test-project", "Test Project") - - # Create issues with dependencies - issue1_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1", - title="Issue 1", - description="First issue", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # Create issue 2 with dependency on issue 1 via raw SQL - cursor = db.conn.cursor() - cursor.execute( - """INSERT INTO issues (project_id, issue_number, title, description, status, priority, depends_on) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - (project_id, "2", "Issue 2", "Depends on Issue 1", "pending", 1, json.dumps([str(issue1_id)])), - ) - db.conn.commit() - - # Get issues with tasks - result = db.get_issues_with_tasks(project_id, include_tasks=False) - - assert len(result["issues"]) == 2 - - # Find issue 2 and check depends_on - issue2 = next(i for i in result["issues"] if i["issue_number"] == "2") - assert issue2["depends_on"] == [str(issue1_id)], "depends_on should be parsed from JSON" - - def test_get_issues_with_tasks_handles_null_depends_on(self, db): - """Test that get_issues_with_tasks handles NULL depends_on gracefully.""" - project_id = db.create_project("test-project", "Test Project") - - # Create issue without dependencies - db.create_issue( - Issue( - project_id=project_id, - issue_number="1", - title="Issue 1", - description="No dependencies", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # Get issues with tasks - result = db.get_issues_with_tasks(project_id, include_tasks=False) - - assert len(result["issues"]) == 1 - assert result["issues"][0]["depends_on"] == [], "NULL depends_on should return empty list" - - def test_get_issues_with_tasks_handles_invalid_json(self, db): - """Test that get_issues_with_tasks handles invalid JSON in depends_on.""" - project_id = db.create_project("test-project", "Test Project") - - # Create issue with invalid JSON in depends_on via raw SQL - cursor = db.conn.cursor() - cursor.execute( - """INSERT INTO issues (project_id, issue_number, title, description, status, priority, depends_on) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - (project_id, "1", "Issue 1", "Invalid JSON", "pending", 1, "not valid json"), - ) - db.conn.commit() - - # Get issues with tasks - should not raise, should return empty list - result = db.get_issues_with_tasks(project_id, include_tasks=False) - - assert len(result["issues"]) == 1 - assert result["issues"][0]["depends_on"] == [], "Invalid JSON should return empty list" - - def test_get_issues_with_tasks_handles_non_list_json(self, db): - """Test that get_issues_with_tasks handles non-list JSON in depends_on.""" - project_id = db.create_project("test-project", "Test Project") - - # Create issue with non-list JSON via raw SQL - cursor = db.conn.cursor() - cursor.execute( - """INSERT INTO issues (project_id, issue_number, title, description, status, priority, depends_on) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - (project_id, "1", "Issue 1", "Non-list JSON", "pending", 1, json.dumps({"not": "a list"})), - ) - db.conn.commit() - - # Get issues with tasks - should return empty list for non-list JSON - result = db.get_issues_with_tasks(project_id, include_tasks=False) - - assert len(result["issues"]) == 1 - assert result["issues"][0]["depends_on"] == [], "Non-list JSON should return empty list" - - def test_update_issue_depends_on(self, db): - """Test updating issue depends_on field.""" - project_id = db.create_project("test-project", "Test Project") - - # Create two issues - issue1_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="1", - title="Issue 1", - description="First", - status=TaskStatus.PENDING, - priority=1, - ) - ) - issue2_id = db.create_issue( - Issue( - project_id=project_id, - issue_number="2", - title="Issue 2", - description="Second", - status=TaskStatus.PENDING, - priority=1, - ) - ) - - # Update issue2 to depend on issue1 - result = db.update_issue(issue2_id, {"depends_on": json.dumps([str(issue1_id)])}) - assert result == 1, "One row should be updated" - - # Verify via get_issues_with_tasks - issues_result = db.get_issues_with_tasks(project_id, include_tasks=False) - issue2 = next(i for i in issues_result["issues"] if i["issue_number"] == "2") - assert issue2["depends_on"] == [str(issue1_id)] - - -@pytest.mark.unit -class TestProjectAuditLogging: - """Test audit logging for project lifecycle events.""" - - def test_update_project_logs_audit_event(self, db): - """Test that update_project creates audit log with PROJECT_UPDATED event.""" - # Create project with user - user_id = 1 # Assume user exists - project_id = db.create_project("test-project", "Test Project", user_id=user_id) - - # Update project with user_id for audit logging - db.update_project(project_id, {"name": "updated-name"}, user_id=user_id) - - # Verify audit log was created - cursor = db.conn.cursor() - cursor.execute( - """SELECT * FROM audit_logs - WHERE event_type = 'project.updated' - AND resource_type = 'project' - AND resource_id = ?""", - (project_id,), - ) - audit_log = cursor.fetchone() - - assert audit_log is not None, "Audit log should be created for project update" - assert dict(audit_log)["user_id"] == user_id - - def test_update_project_audit_includes_updated_fields(self, db): - """Test that PROJECT_UPDATED audit log includes metadata about updated fields.""" - user_id = 1 - project_id = db.create_project("test-project", "Test Project", user_id=user_id) - - # Update multiple fields - db.update_project( - project_id, - {"name": "updated-name", "description": "updated description"}, - user_id=user_id, - ) - - # Verify audit log metadata contains updated fields - cursor = db.conn.cursor() - cursor.execute( - """SELECT metadata FROM audit_logs - WHERE event_type = 'project.updated' AND resource_id = ?""", - (project_id,), - ) - row = cursor.fetchone() - assert row is not None - - metadata = json.loads(row[0]) if row[0] else {} - assert "updated_fields" in metadata - assert "name" in metadata["updated_fields"] - assert "description" in metadata["updated_fields"] - - def test_update_project_without_user_id_skips_audit(self, db): - """Test that update_project without user_id skips audit logging.""" - project_id = db.create_project("test-project", "Test Project") - - # Update without user_id (e.g., system operation) - db.update_project(project_id, {"name": "updated-name"}) - - # Verify no audit log was created - cursor = db.conn.cursor() - cursor.execute( - """SELECT COUNT(*) FROM audit_logs - WHERE event_type = 'project.updated' AND resource_id = ?""", - (project_id,), - ) - count = cursor.fetchone()[0] - assert count == 0, "No audit log should be created without user_id" - - def test_delete_project_logs_audit_event(self, db): - """Test that delete_project creates audit log with PROJECT_DELETED event.""" - user_id = 1 - project_id = db.create_project("test-project", "Test Project", user_id=user_id) - project_name = "test-project" - - # Delete project with user_id for audit logging - db.delete_project(project_id, user_id=user_id) - - # Verify audit log was created (note: project is deleted but audit log persists) - cursor = db.conn.cursor() - cursor.execute( - """SELECT * FROM audit_logs - WHERE event_type = 'project.deleted' - AND resource_type = 'project' - AND resource_id = ?""", - (project_id,), - ) - audit_log = cursor.fetchone() - - assert audit_log is not None, "Audit log should be created for project deletion" - assert dict(audit_log)["user_id"] == user_id - - # Verify metadata includes project name - metadata = json.loads(dict(audit_log)["metadata"]) if dict(audit_log)["metadata"] else {} - assert metadata.get("name") == project_name - - def test_delete_project_without_user_id_skips_audit(self, db): - """Test that delete_project without user_id skips audit logging.""" - project_id = db.create_project("test-project", "Test Project") - - # Delete without user_id (e.g., cleanup operation) - db.delete_project(project_id) - - # Verify no audit log was created - cursor = db.conn.cursor() - cursor.execute( - """SELECT COUNT(*) FROM audit_logs - WHERE event_type = 'project.deleted' AND resource_id = ?""", - (project_id,), - ) - count = cursor.fetchone()[0] - assert count == 0 - - def test_update_project_audit_with_ip_address(self, db): - """Test that PROJECT_UPDATED audit log can include IP address.""" - user_id = 1 - project_id = db.create_project("test-project", "Test Project", user_id=user_id) - - # Update with IP address - db.update_project( - project_id, - {"name": "updated-name"}, - user_id=user_id, - ip_address="192.168.1.100", - ) - - # Verify audit log includes IP address - cursor = db.conn.cursor() - cursor.execute( - """SELECT ip_address FROM audit_logs - WHERE event_type = 'project.updated' AND resource_id = ?""", - (project_id,), - ) - row = cursor.fetchone() - assert row is not None - assert row[0] == "192.168.1.100" - - def test_delete_project_audit_with_ip_address(self, db): - """Test that PROJECT_DELETED audit log can include IP address.""" - user_id = 1 - project_id = db.create_project("test-project", "Test Project", user_id=user_id) - - # Delete with IP address - db.delete_project(project_id, user_id=user_id, ip_address="10.0.0.1") - - # Verify audit log includes IP address - cursor = db.conn.cursor() - cursor.execute( - """SELECT ip_address FROM audit_logs - WHERE event_type = 'project.deleted' AND resource_id = ?""", - (project_id,), - ) - row = cursor.fetchone() - assert row is not None - assert row[0] == "10.0.0.1" - - -@pytest.mark.unit -class TestAuditLogIntegrity: - """Test audit log data integrity and consistency.""" - - def test_project_lifecycle_audit_trail(self, db): - """Test complete audit trail for project lifecycle: create → update → delete.""" - user_id = 1 - - # Create - project_id = db.create_project("lifecycle-test", "Test lifecycle", user_id=user_id) - - # Update - db.update_project(project_id, {"description": "Updated desc"}, user_id=user_id) - - # Delete - db.delete_project(project_id, user_id=user_id) - - # Verify complete audit trail - cursor = db.conn.cursor() - cursor.execute( - """SELECT event_type FROM audit_logs - WHERE resource_type = 'project' AND resource_id = ? - ORDER BY timestamp""", - (project_id,), - ) - events = [row[0] for row in cursor.fetchall()] - - assert "project.created" in events - assert "project.updated" in events - assert "project.deleted" in events - - def test_audit_log_timestamps_are_sequential(self, db): - """Test that audit log timestamps are in chronological order.""" - user_id = 1 - project_id = db.create_project("timestamp-test", "Test", user_id=user_id) - - # Perform multiple operations - import time - db.update_project(project_id, {"name": "update1"}, user_id=user_id) - time.sleep(0.01) # Small delay to ensure different timestamps - db.update_project(project_id, {"name": "update2"}, user_id=user_id) - - # Verify timestamps are sequential - cursor = db.conn.cursor() - cursor.execute( - """SELECT timestamp FROM audit_logs - WHERE resource_type = 'project' AND resource_id = ? - ORDER BY id""", - (project_id,), - ) - timestamps = [row[0] for row in cursor.fetchall()] - - assert len(timestamps) >= 2, "Should have at least 2 audit logs" - # Timestamps should be chronologically ordered - for i in range(1, len(timestamps)): - assert timestamps[i] >= timestamps[i - 1], "Timestamps should be sequential" diff --git a/tests/persistence/test_database_typed_returns.py b/tests/persistence/test_database_typed_returns.py deleted file mode 100644 index b234e4e1..00000000 --- a/tests/persistence/test_database_typed_returns.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Tests for typed database returns and async operations. - -These tests verify: -1. Project.to_dict() serialization works correctly -2. Async connection cleanup via context manager -3. IssueWithTaskCount composition pattern -4. NULL created_at raises ValueError (defensive validation) -""" - -import tempfile -from datetime import datetime -from pathlib import Path - -import pytest - -from codeframe.core.models import ( - Issue, - IssueWithTaskCount, - Project, - ProjectPhase, - ProjectStatus, - SourceType, - TaskStatus, - VALID_TASK_STATUSES, -) -from codeframe.persistence.database import Database - - -@pytest.fixture -def temp_db_path(): - """Create a temporary database file.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) / "test.db" - - -@pytest.fixture -def db(temp_db_path): - """Create an initialized database.""" - database = Database(temp_db_path) - database.initialize() - yield database - database.close() - - -class TestValidTaskStatuses: - """Tests for VALID_TASK_STATUSES constant.""" - - def test_valid_task_statuses_is_frozenset(self): - """VALID_TASK_STATUSES should be a frozenset.""" - assert isinstance(VALID_TASK_STATUSES, frozenset) - - def test_valid_task_statuses_contains_all_enum_values(self): - """VALID_TASK_STATUSES should contain all TaskStatus enum values.""" - for status in TaskStatus: - assert status.value in VALID_TASK_STATUSES - - def test_valid_task_statuses_has_expected_values(self): - """VALID_TASK_STATUSES should contain known status values.""" - expected = {"pending", "assigned", "in_progress", "blocked", "completed", "failed"} - assert VALID_TASK_STATUSES == expected - - -class TestProjectToDict: - """Tests for Project.to_dict() serialization.""" - - def test_project_to_dict_basic(self): - """Test basic Project.to_dict() serialization.""" - project = Project( - id=1, - name="Test Project", - description="A test project", - source_type=SourceType.GIT_REMOTE, - source_location="https://github.com/test/repo", - source_branch="main", - workspace_path="/tmp/workspace", - status=ProjectStatus.ACTIVE, - phase=ProjectPhase.PLANNING, - ) - - result = project.to_dict() - - assert result["id"] == 1 - assert result["name"] == "Test Project" - assert result["description"] == "A test project" - assert result["source_type"] == "git_remote" - assert result["source_location"] == "https://github.com/test/repo" - assert result["source_branch"] == "main" - assert result["workspace_path"] == "/tmp/workspace" - assert result["status"] == "active" - assert result["phase"] == "planning" - assert result["created_at"] is not None - - def test_project_to_dict_with_none_values(self): - """Test Project.to_dict() handles None values correctly.""" - project = Project( - name="Test", - description="Test", - workspace_path="/tmp", - ) - - result = project.to_dict() - - assert result["id"] is None - assert result["source_location"] is None - assert result["current_commit"] is None - assert result["paused_at"] is None - assert result["config"] is None - - def test_project_to_dict_serializes_datetime(self): - """Test Project.to_dict() serializes datetime to ISO format.""" - created = datetime(2025, 1, 15, 10, 30, 0) - project = Project( - name="Test", - description="Test", - workspace_path="/tmp", - created_at=created, - ) - - result = project.to_dict() - - assert result["created_at"] == "2025-01-15T10:30:00" - - -class TestIssueWithTaskCountComposition: - """Tests for IssueWithTaskCount composition pattern.""" - - def test_issue_with_task_count_wraps_issue(self): - """IssueWithTaskCount should wrap an Issue object.""" - issue = Issue( - id=1, - project_id=1, - issue_number="1.0", - title="Test Issue", - status=TaskStatus.IN_PROGRESS, - ) - - issue_with_count = IssueWithTaskCount(issue=issue, task_count=5) - - assert issue_with_count.issue is issue - assert issue_with_count.task_count == 5 - - def test_issue_with_task_count_convenience_accessors(self): - """IssueWithTaskCount should expose Issue fields via properties.""" - issue = Issue( - id=42, - project_id=7, - issue_number="2.1", - title="Feature X", - status=TaskStatus.COMPLETED, - ) - - issue_with_count = IssueWithTaskCount(issue=issue, task_count=3) - - assert issue_with_count.id == 42 - assert issue_with_count.project_id == 7 - assert issue_with_count.issue_number == "2.1" - assert issue_with_count.title == "Feature X" - assert issue_with_count.status == TaskStatus.COMPLETED - - def test_issue_with_task_count_to_dict(self): - """IssueWithTaskCount.to_dict() should include task_count.""" - issue = Issue( - id=1, - project_id=1, - issue_number="1.0", - title="Test", - ) - - issue_with_count = IssueWithTaskCount(issue=issue, task_count=10) - result = issue_with_count.to_dict() - - # Should have all Issue fields plus task_count - assert "id" in result - assert "issue_number" in result - assert "title" in result - assert "task_count" in result - assert result["task_count"] == 10 - - -class TestAsyncConnectionCleanup: - """Tests for async connection cleanup.""" - - @pytest.mark.asyncio - async def test_async_context_manager_closes_connection(self, db): - """Async context manager should close async connection on exit.""" - async with db: - # Connection should be created/available - pass - - # After context exit, async connection should be closed - assert db._async_conn is None - - @pytest.mark.asyncio - async def test_async_context_manager_initializes_sync_connection(self, temp_db_path): - """Async context manager should initialize sync connection if not already done.""" - db = Database(temp_db_path) - - async with db: - # Sync connection should be initialized - assert db.conn is not None - - db.close() - - @pytest.mark.asyncio - async def test_close_async_is_idempotent(self, db): - """Calling close_async() multiple times should be safe.""" - async with db: - pass - - # Should not raise - await db.close_async() - await db.close_async() - - assert db._async_conn is None - - -class TestCreatedAtSchemaConstraint: - """Tests verifying that the schema enforces NOT NULL on created_at. - - The v1.0 schema enforces NOT NULL constraints on created_at columns. - These tests verify that the database schema prevents NULL values. - """ - - @pytest.fixture - def db(self, temp_db_path): - """Database with v1.0 schema.""" - db = Database(temp_db_path) - db.initialize() - yield db - db.close() - - def test_tasks_created_at_not_null_enforced(self, db): - """Schema should prevent NULL created_at in tasks table.""" - cursor = db.conn.cursor() - - # Create project and issue first - cursor.execute( - "INSERT INTO projects (name, description, workspace_path, status) " - "VALUES ('Test', 'Test', '/tmp', 'active')" - ) - project_id = cursor.lastrowid - - cursor.execute( - "INSERT INTO issues (project_id, issue_number, title, status) " - "VALUES (?, '1.0', 'Test Issue', 'pending')", - (project_id,), - ) - issue_id = cursor.lastrowid - - # Attempting to insert task with NULL created_at should fail - with pytest.raises(Exception): # sqlite3.IntegrityError - cursor.execute( - "INSERT INTO tasks (project_id, issue_id, title, status, created_at) " - "VALUES (?, ?, 'Test Task', 'pending', NULL)", - (project_id, issue_id), - ) - - def test_issues_created_at_not_null_enforced(self, db): - """Schema should prevent NULL created_at in issues table.""" - cursor = db.conn.cursor() - - # Create project first - cursor.execute( - "INSERT INTO projects (name, description, workspace_path, status) " - "VALUES ('Test', 'Test', '/tmp', 'active')" - ) - project_id = cursor.lastrowid - - # Attempting to insert issue with NULL created_at should fail - with pytest.raises(Exception): # sqlite3.IntegrityError - cursor.execute( - "INSERT INTO issues (project_id, issue_number, title, status, created_at) " - "VALUES (?, '1.0', 'Test Issue', 'pending', NULL)", - (project_id,), - ) - - -class TestGetIssueWithTaskCounts: - """Tests for get_issue_with_task_counts with composition.""" - - @pytest.fixture - def db(self, temp_db_path): - """Database with v1.0 schema.""" - db = Database(temp_db_path) - db.initialize() - yield db - db.close() - - @pytest.fixture - def db_with_data(self, db): - """Database with project and issue created.""" - project_id = db.create_project( - name="Test Project", - description="Test", - workspace_path="/tmp/test", - ) - - issue = Issue( - project_id=project_id, - issue_number="1.0", - title="Test Issue", - status=TaskStatus.PENDING, - ) - issue_id = db.create_issue(issue) - - return db, project_id, issue_id - - def test_get_issue_with_task_counts_returns_typed_object(self, db_with_data): - """get_issue_with_task_counts should return IssueWithTaskCount.""" - db, project_id, issue_id = db_with_data - - # Create tasks - cursor = db.conn.cursor() - for i in range(3): - cursor.execute( - """ - INSERT INTO tasks ( - project_id, issue_id, task_number, parent_issue_number, - title, description, status, priority, workflow_step, - can_parallelize, requires_mcp, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) - """, - ( - project_id, - issue_id, - f"1.0.{i+1}", - "1.0", - f"Task {i+1}", - "Test task", - "pending", - 2, - 1, - False, - False, - ), - ) - db.conn.commit() - - result = db.get_issue_with_task_counts(issue_id) - - assert isinstance(result, IssueWithTaskCount) - assert result.task_count == 3 - assert result.issue_number == "1.0" - assert result.title == "Test Issue" - - def test_get_issue_with_task_counts_returns_none_for_nonexistent(self, db): - """get_issue_with_task_counts should return None for nonexistent issue.""" - result = db.get_issue_with_task_counts(99999) - assert result is None - - def test_get_issue_with_task_counts_zero_tasks(self, db_with_data): - """get_issue_with_task_counts should return 0 for issue with no tasks.""" - db, project_id, issue_id = db_with_data - - result = db.get_issue_with_task_counts(issue_id) - - assert result.task_count == 0 diff --git a/tests/persistence/test_project_agents.py b/tests/persistence/test_project_agents.py deleted file mode 100644 index 1c6b34cd..00000000 --- a/tests/persistence/test_project_agents.py +++ /dev/null @@ -1,488 +0,0 @@ -"""Tests for project_agents junction table and multi-agent methods.""" - -import pytest -import sqlite3 -from codeframe.persistence.database import Database -from codeframe.core.models import AgentMaturity - - -@pytest.fixture -def db(): - """Create an in-memory database for testing.""" - db = Database(":memory:") - db.initialize() - return db - - -@pytest.fixture -def sample_project(db): - """Create a sample project for testing.""" - project_id = db.create_project( - name="test-project", description="Test project for multi-agent", workspace_path="/tmp/test" - ) - return project_id - - -@pytest.fixture -def sample_agents(db): - """Create sample agents for testing.""" - agent_ids = [] - for i in range(3): - agent_id = f"agent-{i:03d}" - db.create_agent( - agent_id=agent_id, - agent_type="backend" if i < 2 else "frontend", - provider="claude", - maturity_level=AgentMaturity.D4, - ) - agent_ids.append(agent_id) - return agent_ids - - -# Schema Tests -def test_project_agents_table_exists(db): - """Verify project_agents table exists with correct schema.""" - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(project_agents)") - columns = {row[1]: row[2] for row in cursor.fetchall()} - - # Verify all columns exist - assert "id" in columns, "Missing id column" - assert "project_id" in columns, "Missing project_id column" - assert "agent_id" in columns, "Missing agent_id column" - assert "role" in columns, "Missing role column" - assert "assigned_at" in columns, "Missing assigned_at column" - assert "unassigned_at" in columns, "Missing unassigned_at column" - assert "is_active" in columns, "Missing is_active column" - - -def test_project_agents_indexes_exist(db): - """Verify all performance indexes exist.""" - cursor = db.conn.cursor() - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='project_agents'" - ) - indexes = {row[0] for row in cursor.fetchall()} - - expected_indexes = { - "idx_project_agents_project_active", - "idx_project_agents_agent_active", - "idx_project_agents_assigned_at", - "idx_project_agents_unassigned", - "idx_project_agents_unique_active", - } - - assert expected_indexes.issubset(indexes), f"Missing indexes: {expected_indexes - indexes}" - - -def test_agents_table_has_project_id(db): - """Verify agents table has project_id column for multi-project support.""" - cursor = db.conn.cursor() - cursor.execute("PRAGMA table_info(agents)") - columns = {row[1] for row in cursor.fetchall()} - - assert "project_id" in columns, "agents table should have project_id column" - assert "created_at" in columns, "agents table should have created_at column" - - -# assign_agent_to_project() Tests -def test_assign_agent_to_project(db, sample_project, sample_agents): - """Test assigning an agent to a project.""" - assignment_id = db.assign_agent_to_project( - project_id=sample_project, agent_id=sample_agents[0], role="primary_backend" - ) - - assert assignment_id is not None - assert assignment_id > 0 - - -def test_assign_multiple_agents_to_project(db, sample_project, sample_agents): - """Test assigning multiple agents to same project.""" - for i, agent_id in enumerate(sample_agents): - assignment_id = db.assign_agent_to_project( - project_id=sample_project, agent_id=agent_id, role=f"role-{i}" - ) - assert assignment_id > 0 - - -def test_assign_agent_to_multiple_projects(db, sample_agents): - """Test assigning same agent to multiple projects.""" - project1 = db.create_project(name="project1", description="Project 1", workspace_path="/tmp/p1") - project2 = db.create_project(name="project2", description="Project 2", workspace_path="/tmp/p2") - - agent_id = sample_agents[0] - - assignment1 = db.assign_agent_to_project(project1, agent_id, "backend") - assignment2 = db.assign_agent_to_project(project2, agent_id, "reviewer") - - assert assignment1 != assignment2 - - -def test_assign_duplicate_active_agent_fails(db, sample_project, sample_agents): - """Test that assigning same agent to same project twice (while active) fails.""" - agent_id = sample_agents[0] - - db.assign_agent_to_project(sample_project, agent_id, "backend") - - with pytest.raises(sqlite3.IntegrityError) as exc_info: - db.assign_agent_to_project(sample_project, agent_id, "backend") - - assert "UNIQUE constraint failed" in str(exc_info.value) - - -def test_assign_agent_after_removal_succeeds(db, sample_project, sample_agents): - """Test that reassigning after removal works.""" - agent_id = sample_agents[0] - - # First assignment - db.assign_agent_to_project(sample_project, agent_id, "backend") - - # Remove agent - db.remove_agent_from_project(sample_project, agent_id) - - # Reassign (should work now) - assignment_id = db.assign_agent_to_project(sample_project, agent_id, "reviewer") - assert assignment_id > 0 - - -# get_agents_for_project() Tests -def test_get_agents_for_project_empty(db, sample_project): - """Test getting agents for project with no assignments.""" - agents = db.get_agents_for_project(sample_project) - assert agents == [] - - -def test_get_agents_for_project_active_only(db, sample_project, sample_agents): - """Test getting only active agents for project.""" - # Assign 2 agents - db.assign_agent_to_project(sample_project, sample_agents[0], "backend") - db.assign_agent_to_project(sample_project, sample_agents[1], "reviewer") - - # Remove one agent - db.remove_agent_from_project(sample_project, sample_agents[1]) - - # Get active agents - agents = db.get_agents_for_project(sample_project, active_only=True) - - assert len(agents) == 1 - assert agents[0]["agent_id"] == sample_agents[0] - assert agents[0]["role"] == "backend" - assert agents[0]["is_active"] == 1 - - -def test_get_agents_for_project_all(db, sample_project, sample_agents): - """Test getting all agents (active and inactive) for project.""" - # Assign 2 agents - db.assign_agent_to_project(sample_project, sample_agents[0], "backend") - db.assign_agent_to_project(sample_project, sample_agents[1], "reviewer") - - # Remove one agent - db.remove_agent_from_project(sample_project, sample_agents[1]) - - # Get all agents - agents = db.get_agents_for_project(sample_project, active_only=False) - - assert len(agents) == 2 - - -def test_get_agents_for_project_includes_metadata(db, sample_project, sample_agents): - """Test that get_agents_for_project returns agent metadata.""" - db.assign_agent_to_project(sample_project, sample_agents[0], "backend") - - agents = db.get_agents_for_project(sample_project) - - assert len(agents) == 1 - agent = agents[0] - - # Check agent fields - assert agent["agent_id"] == sample_agents[0] - assert agent["type"] == "backend" - assert agent["provider"] == "claude" - assert agent["maturity_level"] == "delegating" - - # Check assignment fields - assert agent["role"] == "backend" - assert agent["assigned_at"] is not None - assert agent["is_active"] == 1 - - -# get_projects_for_agent() Tests -def test_get_projects_for_agent_empty(db, sample_agents): - """Test getting projects for agent with no assignments.""" - projects = db.get_projects_for_agent(sample_agents[0]) - assert projects == [] - - -def test_get_projects_for_agent_active_only(db, sample_agents): - """Test getting only active projects for agent.""" - project1 = db.create_project(name="project1", description="Project 1", workspace_path="/tmp/p1") - project2 = db.create_project(name="project2", description="Project 2", workspace_path="/tmp/p2") - - agent_id = sample_agents[0] - - # Assign to both projects - db.assign_agent_to_project(project1, agent_id, "backend") - db.assign_agent_to_project(project2, agent_id, "reviewer") - - # Remove from project2 - db.remove_agent_from_project(project2, agent_id) - - # Get active projects - projects = db.get_projects_for_agent(agent_id, active_only=True) - - assert len(projects) == 1 - assert projects[0]["project_id"] == project1 - assert projects[0]["role"] == "backend" - - -def test_get_projects_for_agent_all(db, sample_agents): - """Test getting all projects (active and inactive) for agent.""" - project1 = db.create_project(name="project1", description="Project 1", workspace_path="/tmp/p1") - project2 = db.create_project(name="project2", description="Project 2", workspace_path="/tmp/p2") - - agent_id = sample_agents[0] - - # Assign to both projects - db.assign_agent_to_project(project1, agent_id, "backend") - db.assign_agent_to_project(project2, agent_id, "reviewer") - - # Remove from project2 - db.remove_agent_from_project(project2, agent_id) - - # Get all projects - projects = db.get_projects_for_agent(agent_id, active_only=False) - - assert len(projects) == 2 - - -# remove_agent_from_project() Tests -def test_remove_agent_from_project(db, sample_project, sample_agents): - """Test removing agent from project (soft delete).""" - agent_id = sample_agents[0] - - db.assign_agent_to_project(sample_project, agent_id, "backend") - - rows_affected = db.remove_agent_from_project(sample_project, agent_id) - - assert rows_affected == 1 - - # Verify agent is no longer active - agents = db.get_agents_for_project(sample_project, active_only=True) - assert len(agents) == 0 - - # Verify agent still exists in history - agents = db.get_agents_for_project(sample_project, active_only=False) - assert len(agents) == 1 - assert agents[0]["is_active"] == 0 - assert agents[0]["unassigned_at"] is not None - - -def test_remove_nonexistent_assignment(db, sample_project, sample_agents): - """Test removing agent that's not assigned returns 0.""" - rows_affected = db.remove_agent_from_project(sample_project, sample_agents[0]) - assert rows_affected == 0 - - -def test_remove_already_removed_agent(db, sample_project, sample_agents): - """Test removing already-removed agent returns 0.""" - agent_id = sample_agents[0] - - db.assign_agent_to_project(sample_project, agent_id, "backend") - db.remove_agent_from_project(sample_project, agent_id) - - # Try to remove again - rows_affected = db.remove_agent_from_project(sample_project, agent_id) - assert rows_affected == 0 - - -# get_agent_assignment() Tests -def test_get_agent_assignment(db, sample_project, sample_agents): - """Test getting assignment details for agent-project pair.""" - agent_id = sample_agents[0] - - db.assign_agent_to_project(sample_project, agent_id, "backend") - - assignment = db.get_agent_assignment(sample_project, agent_id) - - assert assignment is not None - assert assignment["project_id"] == sample_project - assert assignment["agent_id"] == agent_id - assert assignment["role"] == "backend" - assert assignment["is_active"] == 1 - assert assignment["unassigned_at"] is None - - -def test_get_agent_assignment_nonexistent(db, sample_project, sample_agents): - """Test getting nonexistent assignment returns None.""" - assignment = db.get_agent_assignment(sample_project, sample_agents[0]) - assert assignment is None - - -def test_get_agent_assignment_returns_latest(db, sample_project, sample_agents): - """Test that get_agent_assignment returns most recent assignment.""" - agent_id = sample_agents[0] - - # First assignment - db.assign_agent_to_project(sample_project, agent_id, "backend") - db.remove_agent_from_project(sample_project, agent_id) - - # Second assignment - db.assign_agent_to_project(sample_project, agent_id, "reviewer") - - assignment = db.get_agent_assignment(sample_project, agent_id) - - assert assignment["role"] == "reviewer" - assert assignment["is_active"] == 1 - - -# reassign_agent_role() Tests -def test_reassign_agent_role(db, sample_project, sample_agents): - """Test updating agent's role on project.""" - agent_id = sample_agents[0] - - db.assign_agent_to_project(sample_project, agent_id, "backend") - - rows_affected = db.reassign_agent_role(sample_project, agent_id, "lead_backend") - - assert rows_affected == 1 - - assignment = db.get_agent_assignment(sample_project, agent_id) - assert assignment["role"] == "lead_backend" - - -def test_reassign_role_nonexistent_assignment(db, sample_project, sample_agents): - """Test reassigning role for nonexistent assignment returns 0.""" - rows_affected = db.reassign_agent_role(sample_project, sample_agents[0], "backend") - assert rows_affected == 0 - - -def test_reassign_role_inactive_assignment(db, sample_project, sample_agents): - """Test reassigning role for inactive assignment returns 0.""" - agent_id = sample_agents[0] - - db.assign_agent_to_project(sample_project, agent_id, "backend") - db.remove_agent_from_project(sample_project, agent_id) - - rows_affected = db.reassign_agent_role(sample_project, agent_id, "reviewer") - assert rows_affected == 0 - - -# get_available_agents() Tests -def test_get_available_agents_all(db, sample_agents): - """Test getting all available agents.""" - agents = db.get_available_agents() - - assert len(agents) == 3 - assert agents[0]["active_assignments"] == 0 - - -def test_get_available_agents_filter_by_type(db, sample_agents): - """Test filtering available agents by type.""" - backend_agents = db.get_available_agents(agent_type="backend") - frontend_agents = db.get_available_agents(agent_type="frontend") - - assert len(backend_agents) == 2 - assert len(frontend_agents) == 1 - - -def test_get_available_agents_exclude_project(db, sample_project, sample_agents): - """Test excluding agents already on project.""" - # Assign one agent to project - db.assign_agent_to_project(sample_project, sample_agents[0], "backend") - - # Get available agents excluding this project - agents = db.get_available_agents(exclude_project_id=sample_project) - - # Should return only agents not on project - agent_ids = [a["id"] for a in agents] - assert sample_agents[0] not in agent_ids - assert len(agents) == 2 - - -def test_get_available_agents_respects_capacity(db): - """Test that agents at capacity (3+ projects) are not returned.""" - # Create agent - agent_id = "busy-agent" - db.create_agent(agent_id, "backend", "claude", AgentMaturity.D4) - - # Assign to 3 projects - for i in range(3): - project_id = db.create_project( - name=f"project-{i}", description=f"Project {i}", workspace_path=f"/tmp/p{i}" - ) - db.assign_agent_to_project(project_id, agent_id, "backend") - - # Agent should not appear in available agents - agents = db.get_available_agents() - agent_ids = [a["id"] for a in agents] - assert agent_id not in agent_ids - - -def test_get_available_agents_ordering(db, sample_agents): - """Test that available agents are ordered by assignment count and heartbeat.""" - project1 = db.create_project(name="project1", description="Project 1", workspace_path="/tmp/p1") - - # Assign one agent to a project - db.assign_agent_to_project(project1, sample_agents[0], "backend") - - agents = db.get_available_agents() - - # First agent should have 0 assignments (sample_agents[1] or [2]) - assert agents[0]["active_assignments"] == 0 - - # Last agent should have 1 assignment (sample_agents[0]) - assert agents[-1]["active_assignments"] == 1 - assert agents[-1]["id"] == sample_agents[0] - - -# Foreign Key Constraint Tests -def test_cascade_delete_project(db, sample_project, sample_agents): - """Test that deleting project cascades to project_agents.""" - db.assign_agent_to_project(sample_project, sample_agents[0], "backend") - - # Delete project - cursor = db.conn.cursor() - cursor.execute("DELETE FROM projects WHERE id = ?", (sample_project,)) - db.conn.commit() - - # Verify assignment is deleted - cursor.execute("SELECT COUNT(*) FROM project_agents WHERE project_id = ?", (sample_project,)) - count = cursor.fetchone()[0] - assert count == 0 - - -def test_cascade_delete_agent(db, sample_project, sample_agents): - """Test that deleting agent cascades to project_agents.""" - agent_id = sample_agents[0] - db.assign_agent_to_project(sample_project, agent_id, "backend") - - # Delete agent - cursor = db.conn.cursor() - cursor.execute("DELETE FROM agents WHERE id = ?", (agent_id,)) - db.conn.commit() - - # Verify assignment is deleted - cursor.execute("SELECT COUNT(*) FROM project_agents WHERE agent_id = ?", (agent_id,)) - count = cursor.fetchone()[0] - assert count == 0 - - -# Check Constraint Tests -def test_unassigned_at_check_constraint(db, sample_project, sample_agents): - """Test that unassigned_at must be >= assigned_at.""" - agent_id = sample_agents[0] - db.assign_agent_to_project(sample_project, agent_id, "backend") - - # Try to set unassigned_at to before assigned_at - cursor = db.conn.cursor() - with pytest.raises(sqlite3.IntegrityError) as exc_info: - cursor.execute( - """ - UPDATE project_agents - SET unassigned_at = '2020-01-01 00:00:00' - WHERE project_id = ? AND agent_id = ? - """, - (sample_project, agent_id), - ) - - assert "CHECK constraint failed" in str(exc_info.value) diff --git a/tests/persistence/test_server_database.py b/tests/persistence/test_server_database.py deleted file mode 100644 index 0755c80a..00000000 --- a/tests/persistence/test_server_database.py +++ /dev/null @@ -1,305 +0,0 @@ -"""Tests for Status Server database integration. - -Following TDD: These tests are written FIRST, before implementation. -Task: cf-8.2 - Database initialization on server startup -""" - -import pytest -from fastapi.testclient import TestClient - - -@pytest.mark.unit -class TestServerDatabaseInitialization: - """Test database initialization in server startup.""" - - def test_database_initialized_on_startup(self, temp_db_path): - """Test that database is initialized when server starts.""" - # ARRANGE: Import server module with temp database path - import os - - os.environ["DATABASE_PATH"] = str(temp_db_path) - - # Import app AFTER setting environment variable - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - # ACT: Start the app with TestClient to trigger lifespan - with TestClient(app): - # ASSERT: Database should be initialized - assert hasattr(app.state, "db"), "App should have database in state" - assert app.state.db is not None, "Database should be initialized" - assert app.state.db.conn is not None, "Database connection should be active" - - # Verify database file was created - assert temp_db_path.exists(), "Database file should exist" - - def test_database_tables_created_on_startup(self, temp_db_path): - """Test that all database tables are created on startup.""" - # ARRANGE - import os - - os.environ["DATABASE_PATH"] = str(temp_db_path) - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - # ACT: Start the app with TestClient to trigger lifespan - with TestClient(app): - db = app.state.db - - # ASSERT: Verify all tables exist - cursor = db.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") - tables = [row[0] for row in cursor.fetchall()] - - expected_tables = [ - "projects", - "tasks", - "agents", - "blockers", - "memory", - "context_items", - "checkpoints", - "changelog", - ] - - for table in expected_tables: - assert table in tables, f"Table {table} should be created on startup" - - def test_database_connection_lifecycle(self, temp_db_path): - """Test database connection is properly managed across app lifecycle.""" - # ARRANGE - import os - - os.environ["DATABASE_PATH"] = str(temp_db_path) - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - # ACT & ASSERT: Database should be available during request - with TestClient(app) as client: - # Database should be active during requests - assert app.state.db.conn is not None - - # Make a request to ensure database is accessible - response = client.get("/") - assert response.status_code == 200 - - def test_database_uses_config_path(self, temp_dir): - """Test that database uses path from configuration.""" - # ARRANGE: Set custom database path - custom_db_path = temp_dir / "custom" / "test.db" - import os - - os.environ["DATABASE_PATH"] = str(custom_db_path) - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - # ACT: Start the app with TestClient to trigger lifespan - with TestClient(app): - # ASSERT: Custom path should be used - assert custom_db_path.exists(), "Custom database path should be created" - assert app.state.db.db_path == custom_db_path - - def test_database_connection_survives_requests(self, temp_db_path): - """Test that database connection persists across multiple requests.""" - # ARRANGE - import os - - os.environ["DATABASE_PATH"] = str(temp_db_path) - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - # ACT & ASSERT: Make multiple requests - with TestClient(app) as client: - initial_conn = app.state.db.conn - - # Multiple requests - for _ in range(5): - response = client.get("/") - assert response.status_code == 200 - - # Connection should be the same - assert app.state.db.conn is initial_conn, "Connection should persist" - - -@pytest.mark.unit -class TestServerDatabaseAccess: - """Test that endpoints can access database.""" - - def test_database_accessible_from_endpoint(self, temp_db_path): - """Test that database is accessible from API endpoints.""" - # ARRANGE - import os - from tests.helpers import create_test_jwt_token, setup_test_user - - os.environ["DATABASE_PATH"] = str(temp_db_path) - - # Reset auth engine to pick up new DATABASE_PATH - from codeframe.auth.manager import reset_auth_engine - reset_auth_engine() - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - # ACT & ASSERT: Endpoint should be able to access database - with TestClient(app) as client: - # Create test user for authentication - db = app.state.db - setup_test_user(db, user_id=1) - - # Create a test project in database - db.create_project("test-project", "Test Project project", user_id=1) - - # Add authentication header - auth_token = create_test_jwt_token(user_id=1) - client.headers["Authorization"] = f"Bearer {auth_token}" - - response = client.get("/api/projects") - assert response.status_code == 200 - # Note: Actual data retrieval tested in cf-8.3 - - -@pytest.mark.unit -class TestServerDatabaseErrorHandling: - """Test error handling for database operations.""" - - def test_server_handles_database_initialization_error(self): - """Test that server handles database initialization errors gracefully.""" - # ARRANGE: Use invalid database path - import os - - os.environ["DATABASE_PATH"] = "/invalid/path/that/cannot/be/created/test.db" - - from codeframe.ui import server - from importlib import reload - - # ACT & ASSERT: Server should handle error (not crash) - try: - reload(server) - # If we get here, error was handled gracefully - assert True - except PermissionError: - # Expected - cannot create directory - assert True - - def test_database_path_defaults_correctly(self, tmp_path): - """Test that database path defaults to .codeframe/state.db if not configured.""" - # ARRANGE: Clear DATABASE_PATH from environment and set WORKSPACE_ROOT to temp dir - import os - - if "DATABASE_PATH" in os.environ: - del os.environ["DATABASE_PATH"] - - # Set WORKSPACE_ROOT to temporary directory to avoid conflicts with existing .codeframe/state.db - os.environ["WORKSPACE_ROOT"] = str(tmp_path) - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - try: - # ACT: Start the app with TestClient to trigger lifespan - with TestClient(app): - # ASSERT: Should use default path under workspace root - expected_default = tmp_path / ".codeframe/state.db" - assert app.state.db.db_path == expected_default - finally: - # Clean up environment - if "WORKSPACE_ROOT" in os.environ: - del os.environ["WORKSPACE_ROOT"] - - -@pytest.mark.integration -class TestServerDatabaseIntegration: - """Integration tests for server with database.""" - - def test_server_startup_with_database(self, temp_db_path): - """Test complete server startup with database initialization.""" - # ARRANGE - import os - - os.environ["DATABASE_PATH"] = str(temp_db_path) - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - try: - # ACT: Create test client (simulates server startup) - with TestClient(app) as client: - # ASSERT: Server should be running with database - response = client.get("/") - assert response.status_code == 200 - assert response.json()["status"] == "online" - - # Database should be initialized - assert app.state.db is not None - assert app.state.db.conn is not None - finally: - # Clean up environment - if "DATABASE_PATH" in os.environ: - del os.environ["DATABASE_PATH"] - - def test_database_operations_during_requests(self, temp_db_path): - """Test that database operations work during API requests.""" - # ARRANGE - import os - - os.environ["DATABASE_PATH"] = str(temp_db_path) - - from codeframe.ui import server - from importlib import reload - - reload(server) - - app = server.app - - try: - # ACT & ASSERT: Perform database operations during request - with TestClient(app): - # Create project in database - db = app.state.db - project_id = db.create_project("integration-test", "Integration Test project") - - # Verify project was created - project = db.get_project(project_id) - assert project is not None - assert project["name"] == "integration-test" - assert project["status"] == "init" # Default status for new projects - finally: - # Clean up environment - if "DATABASE_PATH" in os.environ: - del os.environ["DATABASE_PATH"] diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 0af6f0df..6a108326 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -162,20 +162,6 @@ def running_server(): # Note: WebSocket uses JWT tokens (same as HTTP endpoints) since FastAPI Users migration session_token = create_test_jwt_token(user_id=1) - # Create test projects (project_id=1, 2, 3) - for project_id in [1, 2, 3]: - try: - db.create_project( - name=f"Test Project {project_id}", - description=f"Test project {project_id} for WebSocket tests", - workspace_path=str(workspace_root / str(project_id)), - user_id=1, - ) - db.conn.commit() - except Exception: - # Project might already exist, that's OK - pass - # Close the database before server starts (server will re-open it) db.close() diff --git a/tests/unit/test_pr_repository.py b/tests/unit/test_pr_repository.py deleted file mode 100644 index 8af0b91f..00000000 --- a/tests/unit/test_pr_repository.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Unit tests for PRRepository (TDD - written before implementation).""" - -import pytest -from datetime import datetime - -from codeframe.persistence.repositories.pr_repository import PRRepository - - -class TestPRRepository: - """Tests for the PRRepository class.""" - - @pytest.fixture - def db(self, tmp_path): - """Create a test database with schema.""" - import sqlite3 - from codeframe.persistence.schema_manager import SchemaManager - - db_path = tmp_path / "test.db" - conn = sqlite3.connect(str(db_path)) - conn.row_factory = sqlite3.Row - - # Create schema - schema_mgr = SchemaManager(conn) - schema_mgr.create_schema() - - return conn - - @pytest.fixture - def repo(self, db): - """Create PRRepository instance.""" - return PRRepository(sync_conn=db) - - @pytest.fixture - def project_id(self, db): - """Create a test project and return its ID.""" - cursor = db.cursor() - cursor.execute( - """ - INSERT INTO projects (name, description, workspace_path, status, phase) - VALUES ('Test Project', 'A test project', '/tmp/test', 'active', 'active') - """ - ) - db.commit() - return cursor.lastrowid - - @pytest.fixture - def issue_id(self, db, project_id): - """Create a test issue and return its ID.""" - cursor = db.cursor() - cursor.execute( - """ - INSERT INTO issues (project_id, issue_number, title, status, priority) - VALUES (?, 'ISSUE-001', 'Test Issue', 'pending', 1) - """, - (project_id,), - ) - db.commit() - return cursor.lastrowid - - def test_create_pr_returns_id(self, repo, project_id, issue_id): - """Test that create_pr returns the new PR ID.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=issue_id, - branch_name="feature/test-branch", - title="Test PR", - body="This is a test PR", - base_branch="main", - head_branch="feature/test-branch", - ) - - assert pr_id is not None - assert isinstance(pr_id, int) - assert pr_id > 0 - - def test_create_pr_without_issue(self, repo, project_id): - """Test creating a PR without an associated issue.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/no-issue", - title="PR without issue", - body="No associated issue", - base_branch="main", - head_branch="feature/no-issue", - ) - - assert pr_id is not None - assert isinstance(pr_id, int) - - def test_get_pr_by_id(self, repo, project_id, issue_id): - """Test retrieving a PR by its ID.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=issue_id, - branch_name="feature/test", - title="Test PR", - body="Test body", - base_branch="main", - head_branch="feature/test", - ) - - pr = repo.get_pr(pr_id) - - assert pr is not None - assert pr["id"] == pr_id - assert pr["project_id"] == project_id - assert pr["issue_id"] == issue_id - assert pr["branch_name"] == "feature/test" - assert pr["title"] == "Test PR" - assert pr["body"] == "Test body" - assert pr["base_branch"] == "main" - assert pr["head_branch"] == "feature/test" - assert pr["status"] == "open" - - def test_get_pr_not_found(self, repo): - """Test that get_pr returns None for non-existent PR.""" - pr = repo.get_pr(99999) - assert pr is None - - def test_update_pr_github_data(self, repo, project_id): - """Test updating PR with GitHub response data.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/gh-test", - title="GitHub Test PR", - body="Test", - base_branch="main", - head_branch="feature/gh-test", - ) - - github_created_at = datetime.now() - repo.update_pr_github_data( - pr_id=pr_id, - pr_number=42, - pr_url="https://github.com/owner/repo/pull/42", - github_created_at=github_created_at, - ) - - pr = repo.get_pr(pr_id) - assert pr["pr_number"] == 42 - assert pr["pr_url"] == "https://github.com/owner/repo/pull/42" - - def test_get_pr_by_number(self, repo, project_id): - """Test retrieving a PR by its GitHub PR number.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/numbered", - title="Numbered PR", - body="Test", - base_branch="main", - head_branch="feature/numbered", - ) - repo.update_pr_github_data( - pr_id=pr_id, - pr_number=123, - pr_url="https://github.com/owner/repo/pull/123", - github_created_at=datetime.now(), - ) - - pr = repo.get_pr_by_number(project_id, 123) - - assert pr is not None - assert pr["pr_number"] == 123 - assert pr["id"] == pr_id - - def test_get_pr_by_number_not_found(self, repo, project_id): - """Test that get_pr_by_number returns None for non-existent PR.""" - pr = repo.get_pr_by_number(project_id, 99999) - assert pr is None - - def test_list_prs_all(self, repo, project_id): - """Test listing all PRs for a project.""" - # Create multiple PRs - for i in range(3): - repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name=f"feature/pr-{i}", - title=f"PR {i}", - body=f"Body {i}", - base_branch="main", - head_branch=f"feature/pr-{i}", - ) - - prs = repo.list_prs(project_id) - - assert len(prs) == 3 - - def test_list_prs_by_status(self, repo, project_id): - """Test listing PRs filtered by status.""" - # Create PRs with different statuses - pr_id1 = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/open", - title="Open PR", - body="Test", - base_branch="main", - head_branch="feature/open", - ) - - pr_id2 = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/merged", - title="Merged PR", - body="Test", - base_branch="main", - head_branch="feature/merged", - ) - repo.update_pr_status(pr_id2, "merged", merge_commit_sha="abc123") - - # List only open PRs - open_prs = repo.list_prs(project_id, status="open") - assert len(open_prs) == 1 - assert open_prs[0]["title"] == "Open PR" - - # List only merged PRs - merged_prs = repo.list_prs(project_id, status="merged") - assert len(merged_prs) == 1 - assert merged_prs[0]["title"] == "Merged PR" - - def test_update_pr_status_to_merged(self, repo, project_id): - """Test updating PR status to merged.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/merge", - title="To Merge", - body="Test", - base_branch="main", - head_branch="feature/merge", - ) - - repo.update_pr_status(pr_id, "merged", merge_commit_sha="def456") - - pr = repo.get_pr(pr_id) - assert pr["status"] == "merged" - assert pr["merge_commit_sha"] == "def456" - assert pr["merged_at"] is not None - - def test_update_pr_status_to_closed(self, repo, project_id): - """Test updating PR status to closed.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/close", - title="To Close", - body="Test", - base_branch="main", - head_branch="feature/close", - ) - - repo.update_pr_status(pr_id, "closed") - - pr = repo.get_pr(pr_id) - assert pr["status"] == "closed" - assert pr["closed_at"] is not None - - def test_get_pr_for_branch(self, repo, project_id): - """Test finding a PR by branch name.""" - repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/unique-branch", - title="Unique Branch PR", - body="Test", - base_branch="main", - head_branch="feature/unique-branch", - ) - - pr = repo.get_pr_for_branch(project_id, "feature/unique-branch") - - assert pr is not None - assert pr["branch_name"] == "feature/unique-branch" - - def test_get_pr_for_branch_not_found(self, repo, project_id): - """Test that get_pr_for_branch returns None for non-existent branch.""" - pr = repo.get_pr_for_branch(project_id, "feature/nonexistent") - assert pr is None - - def test_create_pr_stores_created_at(self, repo, project_id): - """Test that create_pr automatically sets created_at timestamp.""" - pr_id = repo.create_pr( - project_id=project_id, - issue_id=None, - branch_name="feature/timestamp", - title="Timestamp PR", - body="Test", - base_branch="main", - head_branch="feature/timestamp", - ) - - pr = repo.get_pr(pr_id) - assert pr["created_at"] is not None