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